diff --git a/moz.configure b/moz.configure index dd419e1cc0b6..5d9ab52caee7 100755 --- a/moz.configure +++ b/moz.configure @@ -929,7 +929,7 @@ set_config("MOZ_SYSTEM_ZLIB", True, when="--with-system-zlib") option( env="USE_LIBZ_RS", - default=milestone.is_early_beta_or_earlier, + default=True, help="Use libz-rs-sys instead of zlib", when=toolkit & ~with_system_zlib_option, ) diff --git a/waterfox/browser/components/WaterfoxGlue.sys.mjs b/waterfox/browser/components/WaterfoxGlue.sys.mjs index df240930d9ca..ec0dc6d75450 100644 --- a/waterfox/browser/components/WaterfoxGlue.sys.mjs +++ b/waterfox/browser/components/WaterfoxGlue.sys.mjs @@ -18,6 +18,7 @@ ChromeUtils.defineESModuleGetters(lazy, { TabGrouping: "resource:///modules/TabGrouping.sys.mjs", setTimeout: "resource://gre/modules/Timer.sys.mjs", UICustomizations: "resource:///modules/UICustomizations.sys.mjs", + SidebarPreferencesHandler: "resource:///modules/SidebarPreferencesHandler.sys.mjs", }); const WATERFOX_CUSTOMIZATIONS_PREF = @@ -64,6 +65,9 @@ export const WaterfoxGlue = { this.startupManifest = await this.getChromeManifest("startup"); this.privateManifest = await this.getChromeManifest("private"); + // Initialize sidebar prefs handler early so it can observe pane loads + lazy.SidebarPreferencesHandler.init(); + // Observe chrome-document-loaded topic to detect window open Services.obs.addObserver(this, "chrome-document-loaded"); // Observe main-pane-loaded topic to detect about:preferences open diff --git a/waterfox/browser/components/genMozBuild.py b/waterfox/browser/components/genMozBuild.py new file mode 100644 index 000000000000..85401235a583 --- /dev/null +++ b/waterfox/browser/components/genMozBuild.py @@ -0,0 +1,98 @@ +import os +import io +import argparse +import json + +skipFiles = [ + "manifest.json.template", + "test", + "moz.build", +] + +def getFullFileList(outputLoc, dirName): + result = {dirName: []} + for entry in os.listdir(outputLoc): + if entry in skipFiles: + continue + if os.path.isdir(os.path.join(outputLoc, entry)): + result.update(getFullFileList(os.path.join(outputLoc, entry), os.path.join(dirName, entry))) + elif dirName: + result[dirName].append(os.path.join(dirName, entry)) + else: + result[dirName].append(entry) + + return result + +def rewriteMozBuild(outputLoc, fileList, extension_id): + mozBuildFile = os.path.join(outputLoc, "moz.build") + print("Rewriting %s" % mozBuildFile) + + with io.open(mozBuildFile, "w", encoding="UTF-8") as buildFile: + insertion_text = '' + + for dir in sorted(fileList.keys()): + if not fileList[dir]: + continue + + if not dir: + mozBuildPathName = "" + else: + mozBuildPathName = '["' + '"]["'.join(dir.split(os.sep)) + '"]' + + sorted_files = sorted(fileList[dir], key=lambda s: s.lower()) # Sort the files + + insertion_text += \ + "FINAL_TARGET_FILES.features['%s']%s += [\n" % (extension_id, mozBuildPathName) + \ + " '" + \ + "',\n '".join(sorted_files) + "'\n]\n\n" # Use the sorted files + + new_contents = "# AUTOMATIC INSERTION START\n" + insertion_text + "# AUTOMATIC INSERTION END\n" + buildFile.write(new_contents) + +def replace_locale_strings_in_manifest(manifest_path, locale_path): + # Load the manifest file + with open(manifest_path, 'r') as f: + manifest_data = json.load(f) + + # Load the messages file + with open(locale_path, 'r') as f: + messages_data = json.load(f) + + # Recursive function to replace placeholders in nested objects + def replace_placeholders(obj): + if isinstance(obj, dict): + for key in obj: + obj[key] = replace_placeholders(obj[key]) + elif isinstance(obj, str) and obj.startswith('__MSG_') and obj.endswith('__'): + # Extract the message key from the placeholder + message_key = obj[6:-2] + + # Replace the placeholder with the message from the messages file + if message_key in messages_data: + return messages_data[message_key]['message'] + return obj + + # Replace placeholders in the manifest data + manifest_data = replace_placeholders(manifest_data) + + # Write the modified manifest data back to the file + with open(manifest_path, 'w') as f: + json.dump(manifest_data, f, indent=2) + +def main(directory, extension_id): + outputLoc = directory + fileList = getFullFileList(outputLoc, "") + rewriteMozBuild(outputLoc, fileList, extension_id) + + # Call the function with the paths to the manifest.json and messages.json files + manifest_path = os.path.join(directory, 'manifest.json') + locale_path = os.path.join(directory, '_locales', 'en', 'messages.json') + if os.path.exists(manifest_path) and os.path.exists(locale_path): + replace_locale_strings_in_manifest(manifest_path, locale_path) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Generate moz.build file") + parser.add_argument("directory", help="The directory to list files for") + parser.add_argument("extension_id", help="The ID of the extension") + args = parser.parse_args() + main(args.directory, args.extension_id) \ No newline at end of file diff --git a/waterfox/browser/components/moz.build b/waterfox/browser/components/moz.build index 3e449bfb90e9..e38685dd1ab3 100644 --- a/waterfox/browser/components/moz.build +++ b/waterfox/browser/components/moz.build @@ -12,6 +12,7 @@ DIRS += [ "preferences", "privatetab", "search", + "sidebar", "statusbar", "tabfeatures", "tabgrouping", diff --git a/waterfox/browser/components/sidebar/SidebarPreferencesHandler.sys.mjs b/waterfox/browser/components/sidebar/SidebarPreferencesHandler.sys.mjs new file mode 100644 index 000000000000..b227bd92104d --- /dev/null +++ b/waterfox/browser/components/sidebar/SidebarPreferencesHandler.sys.mjs @@ -0,0 +1,617 @@ +/* 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 lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: "resource://gre/modules/AddonManager.sys.mjs", +}); + +const SIDEBAR_ADDON_ID = "sidebar@waterfox.net"; +const SIDEBAR_ADDON_VERSION = "1.1.0"; +const SIDEBAR_ADDON_URI = "resource://builtin-addons/sidebar/"; + +const SIDEBAR_COMPONENT_PREF = "browser.sidebar.enabled"; +const CONTAINERS_CONTROLLER_PREF = "privacy.userContext.extension"; + +export const SidebarPreferencesHandler = { + _initialized: false, + _sidebarPrefObserver: null, + + init() { + if (this._initialized) { + return; + } + this._initialized = true; + + // Watch for about:preferences being loaded so we can patch the UI. + Services.obs.addObserver(this, "main-pane-loaded"); + + // Monitor the sidebar component pref and sync the built-in add-on state. + this._monitorSidebarPref(); + }, + + uninit() { + if (!this._initialized) { + return; + } + this._initialized = false; + + try { + Services.obs.removeObserver(this, "main-pane-loaded"); + } catch (_) {} + + if (this._sidebarPrefObserver) { + try { + Services.prefs.removeObserver(SIDEBAR_COMPONENT_PREF, this._sidebarPrefObserver); + } catch (_) {} + this._sidebarPrefObserver = null; + } + }, + + observe(subject, topic, _data) { + switch (topic) { + case "main-pane-loaded": { + // subject is the about:preferences content window + this._ensureTreeCategory(subject); + this._patchPreferences(subject); + break; + } + } + }, + + async _monitorSidebarPref() { + let addon = await lazy.AddonManager.getAddonByID(SIDEBAR_ADDON_ID); + + // Ensure the built-in sidebar add-on is present (first run or after updates). + addon = + (await lazy.AddonManager.maybeInstallBuiltinAddon( + SIDEBAR_ADDON_ID, + SIDEBAR_ADDON_VERSION, + SIDEBAR_ADDON_URI + )) || addon; + + const syncAddonStateWithPref = async () => { + try { + const disabled = Services.prefs.getBoolPref(SIDEBAR_COMPONENT_PREF, false); + if (disabled) { + if (addon?.isActive) { + await addon.disable({ allowSystemAddons: true }); + } + } else { + if (addon && !addon.isActive) { + await addon.enable({ allowSystemAddons: true }); + } + } + } catch (_) { + // Ignore transient failures. + } + }; + + // Initial sync and observer + await syncAddonStateWithPref(); + this._sidebarPrefObserver = syncAddonStateWithPref; + Services.prefs.addObserver(SIDEBAR_COMPONENT_PREF, syncAddonStateWithPref); + }, + + _ensureTreeCategory(prefsWin) { + const doc = prefsWin.document; + const categories = doc.getElementById("categories"); + if (!categories) { + return; + } + // Bail out if already present + if (doc.getElementById("category-tree")) { + return; + } + // Insert before search category (like previous overlay did) + const before = doc.getElementById("category-search"); + const item = doc.createXULElement("richlistitem"); + item.id = "category-tree"; + item.className = "category"; + item.setAttribute("data-l10n-id", "category-tree"); + item.setAttribute("data-l10n-attrs", "tooltiptext"); + item.setAttribute("value", "paneTree"); + item.setAttribute("align", "center"); + if (before && before.parentNode === categories) { + categories.insertBefore(item, before); + } else { + categories.appendChild(item); + } + const img = doc.createXULElement("image"); + img.className = "category-icon"; + const label = doc.createXULElement("label"); + label.className = "category-name"; + label.setAttribute("flex", "1"); + doc.l10n.setAttributes(label, "pane-tree-title"); + item.appendChild(img); + item.appendChild(label); + + // Ensure the preferences framework knows about our pane so gotoPref works. + if (!prefsWin.gCategoryModules?.has("paneTree")) { + prefsWin.register_module("paneTree", { + init() { + // No-op: our pane is already injected and bound via data-category="paneTree". + }, + }); + console.log("[SidebarPrefs] paneTree registered with preferences"); + } + + // Wire category selection to navigate to our pane + categories.addEventListener("select", () => { + const sel = categories.selectedItem; + if (sel && sel.getAttribute("value") === "paneTree") { + prefsWin.gotoPref("paneTree"); + } + }, { capture: true }); + }, + + _patchPreferences(prefsWin) { + // Wait until the Containers UI has been constructed in about:preferences. + const startedAt = Date.now(); + const timer = prefsWin.setInterval(() => { + if (Date.now() - startedAt > 5000) { + prefsWin.clearInterval(timer); + // Still attempt to inject pane even if containers box wasn't found + this._injectTreePreferences(prefsWin); + return; + } + const doc = prefsWin.document; + if (!doc || doc.readyState === "uninitialized") { + return; + } + const containersBox = doc.getElementById("browserContainersbox"); + if (!containersBox) { + return; + } + prefsWin.clearInterval(timer); + this._hideContainersControlledBannerForBuiltin(prefsWin); + this._injectTreePreferences(prefsWin); + + // Handle initial navigation: allow about:preferences#tree to go to our pane. + const hash = String(doc.location.hash || "").replace(/^#/, ""); + if (hash === "tree" || hash === "paneTree") { + prefsWin.gotoPref("paneTree"); + } + }, 100); + }, + + _hideContainersControlledBannerForBuiltin(prefsWin) { + try { + const controller = Services.prefs.getCharPref(CONTAINERS_CONTROLLER_PREF, ""); + if (controller !== SIDEBAR_ADDON_ID) { + // Some other extension controls containers; do not modify UI. + return; + } + + const doc = prefsWin.document; + const banner = doc.getElementById("browserContainersExtensionContent"); + const checkbox = doc.getElementById("browserContainersCheckbox"); + + // Hide the “controlled by extension” info row. + if (banner) { + banner.hidden = true; + } + + // Optional UX: Ensure the checkbox is enabled so it doesn't look disabled + // without an explanation banner. + if (checkbox) { + checkbox.disabled = false; + } + + // Keep the state enforced if prefs code tries to flip it back. + const target = doc.getElementById("browserContainersbox") || doc; + const mo = new prefsWin.MutationObserver(() => { + const b = doc.getElementById("browserContainersExtensionContent"); + const c = doc.getElementById("browserContainersCheckbox"); + if (b && !b.hidden) { + b.hidden = true; + } + if (c && c.disabled) { + c.disabled = false; + } + }); + mo.observe(target, { childList: true, subtree: true, attributes: true }); + } catch (_) { + // Ignore unexpected contexts. + } + }, + + _injectTreePreferences(prefsWin) { + const doc = prefsWin.document; + + // Avoid duplicating UI if called more than once + if (doc.getElementById("treePreferencesInjected")) { + return; + } + + // Insert our pane elements into the main pane container. We rely on data-category="paneTree" + // so the preferences framework shows/hides it appropriately. + const mainPane = doc.getElementById("mainPrefPane"); + if (!mainPane) { + return; + } + + // Helper to create XUL nodes + const xul = (name, attrs = {}) => { + const el = doc.createXULElement(name); + for (const [k, v] of Object.entries(attrs)) { + if (v !== undefined && v !== null) { + el.setAttribute(k, v); + } + } + return el; + }; + + // Root container + const group = xul("groupbox", { + id: "treePreferencesInjected", + "data-category": "paneTree", + hidden: "true", + }); + + const label = xul("label"); + const h2 = doc.createElementNS("http://www.w3.org/1999/xhtml", "h2"); + label.appendChild(h2); + group.appendChild(label); + doc.l10n.setAttributes(h2, "tree-header"); + + // A small helper to add checkbox bound to a pref with l10n id. + const addCheckbox = (parent, id, pref, l10nId) => { + const box = xul("vbox", { id }); + const cb = xul("checkbox", { id: `${id}-checkbox`, preference: pref }); + doc.l10n.setAttributes(cb, l10nId); + box.appendChild(cb); + parent.appendChild(box); + }; + + // Appearance section + const appearance = xul("groupbox", { + id: "treeAppearanceGroup", + "data-category": "paneTree", + hidden: "true", + }); + { + const l = xul("label"); + const h = doc.createElementNS("http://www.w3.org/1999/xhtml", "h2"); + l.appendChild(h); + doc.l10n.setAttributes(h, "tree-appearance-header"); + appearance.appendChild(l); + addCheckbox( + appearance, + "tree_faviconizePinnedTabsBox", + "browser.sidebar.faviconizePinnedTabs", + "tree-faviconize-pinned-tabs" + ); + } + group.appendChild(appearance); + + // Auto-sticky section + const sticky = xul("groupbox", { + id: "treeAutoStickyGroup", + "data-category": "paneTree", + hidden: "true", + }); + { + const l = xul("label"); + const h = doc.createElementNS("http://www.w3.org/1999/xhtml", "h2"); + l.appendChild(h); + doc.l10n.setAttributes(h, "tree-auto-sticky-header"); + sticky.appendChild(l); + + addCheckbox( + sticky, + "tree_stickyActiveTabBox", + "browser.sidebar.stickyActiveTab", + "tree-sticky-active-tab" + ); + addCheckbox( + sticky, + "tree_stickySoundPlayingTabBox", + "browser.sidebar.stickySoundPlayingTab", + "tree-sticky-sound-playing-tab" + ); + addCheckbox( + sticky, + "tree_stickySharingTabBox", + "browser.sidebar.stickySharingTab", + "tree-sticky-sharing-tab" + ); + } + group.appendChild(sticky); + + // Behavior section + const behavior = xul("groupbox", { + id: "treeBehaviorGroup", + "data-category": "paneTree", + hidden: "true", + }); + { + const l = xul("label"); + const h = doc.createElementNS("http://www.w3.org/1999/xhtml", "h2"); + l.appendChild(h); + doc.l10n.setAttributes(h, "tree-behavior-header"); + behavior.appendChild(l); + + addCheckbox( + behavior, + "tree_autoCollapseExpandSubtreeOnAttachBox", + "browser.sidebar.autoCollapseExpandSubtreeOnAttach", + "tree-auto-collapse-expand-subtree-on-attach" + ); + addCheckbox( + behavior, + "tree_autoCollapseExpandSubtreeOnSelecthBox", + "browser.sidebar.autoCollapseExpandSubtreeOnSelect", + "tree-auto-collapse-expand-subtree-on-select" + ); + + // Double-click behavior menulist + const dbl = xul("hbox", { + id: "tree_treeDoubleClickBehaviorBox", + align: "center", + }); + const dblCaption = xul("label", { + id: "tree_treeDoubleClickBehaviorCaption", + control: "tree_treeDoubleClickBehavior", + }); + doc.l10n.setAttributes(dblCaption, "tree-tree-double-click-behavior-caption"); + dbl.appendChild(dblCaption); + + const dblList = xul("menulist", { + id: "tree_treeDoubleClickBehavior", + preference: "browser.sidebar.treeDoubleClickBehavior", + }); + const dblPopup = xul("menupopup", { class: "in-menulist" }); + const dblOptions = [ + ["1", "tree-tree-double-click-behavior-toggle-collapsed"], + ["4", "tree-tree-double-click-behavior-toggle-sticky"], + ["3", "tree-tree-double-click-behavior-toggle-close"], + ["0", "tree-tree-double-click-behavior-toggle-none"], + ]; + for (const [value, l10nId] of dblOptions) { + const item = xul("menuitem", { value }); + doc.l10n.setAttributes(item, l10nId); + dblPopup.appendChild(item); + } + dblList.appendChild(dblPopup); + dbl.appendChild(dblList); + behavior.appendChild(dbl); + + // Successor control menulist + const suc = xul("hbox", { + id: "tree_successorTabControlLevelBox", + align: "center", + }); + const sucCaption = xul("label", { + id: "tree_successorTabControlLevelCaption", + control: "tree_successorTabControlLevel", + }); + doc.l10n.setAttributes(sucCaption, "tree-successor-tab-control-level-caption"); + suc.appendChild(sucCaption); + + const sucList = xul("menulist", { + id: "tree_successorTabControlLevel", + preference: "browser.sidebar.successorTabControlLevel", + }); + const sucPopup = xul("menupopup", { class: "in-menulist" }); + const sucOptions = [ + ["2", "tree-successor-tab-control-level-in-tree"], + ["1", "tree-successor-tab-control-level-simulate-default"], + ["0", "tree-successor-tab-control-level-never"], + ]; + for (const [value, l10nId] of sucOptions) { + const item = xul("menuitem", { value }); + doc.l10n.setAttributes(item, l10nId); + sucPopup.appendChild(item); + } + sucList.appendChild(sucPopup); + suc.appendChild(sucList); + behavior.appendChild(suc); + + // Drop links behavior menulist + const drop = xul("hbox", { + id: "tree_dropLinksOnTabBehaviorBox", + align: "center", + }); + const dropCaption = xul("label", { + id: "tree_dropLinksOnTabBehaviorCaption", + control: "tree_dropLinksOnTabBehavior", + }); + doc.l10n.setAttributes(dropCaption, "tree-drop-links-on-tab-behavior-caption"); + drop.appendChild(dropCaption); + + const dropList = xul("menulist", { + id: "tree_dropLinksOnTabBehavior", + preference: "browser.sidebar.dropLinksOnTabBehavior", + }); + const dropPopup = xul("menupopup", { class: "in-menulist" }); + const dropOptions = [ + ["0", "tree-drop-links-on-tab-behavior-ask"], + ["1", "tree-drop-links-on-tab-behavior-load"], + ["2", "tree-drop-links-on-tab-behavior-newtab"], + ]; + for (const [value, l10nId] of dropOptions) { + const item = xul("menuitem", { value }); + doc.l10n.setAttributes(item, l10nId); + dropPopup.appendChild(item); + } + dropList.appendChild(dropPopup); + drop.appendChild(dropList); + behavior.appendChild(drop); + } + group.appendChild(behavior); + + // Auto-attach section + const autoAttach = xul("groupbox", { + id: "treeAutoAttachGroup", + "data-category": "paneTree", + hidden: "true", + }); + { + const l = xul("label"); + const h = doc.createElementNS("http://www.w3.org/1999/xhtml", "h2"); + l.appendChild(h); + doc.l10n.setAttributes(h, "tree-auto-attach-header"); + autoAttach.appendChild(l); + + const addMenu = (rootId, captionL10n, pref, options) => { + const box = xul("vbox", { id: rootId }); + const cap = xul("label", { id: `${rootId}Caption`, control: `${rootId}Menu` }); + doc.l10n.setAttributes(cap, captionL10n); + box.appendChild(cap); + const hb = xul("hbox", { id: `${rootId}MenulistBox`, align: "center", class: "sub" }); + const list = xul("menulist", { id: `${rootId.replace(/Box$/, "")}`, preference: pref }); + const popup = xul("menupopup", { class: "in-menulist" }); + for (const [value, l10nId] of options) { + const item = xul("menuitem", { value }); + doc.l10n.setAttributes(item, l10nId); + popup.appendChild(item); + } + list.appendChild(popup); + hb.appendChild(list); + box.appendChild(hb); + autoAttach.appendChild(box); + }; + + addMenu( + "tree_autoAttachOnOpenedWithOwnerBox", + "tree-auto-attach-on-opened-with-owner-caption", + "browser.sidebar.autoAttachOnOpenedWithOwner", + [ + ["-1", "tree-auto-attach-no-control"], + ["0", "tree-auto-attach-independent"], + ["6", "tree-auto-attach-child-top"], + ["7", "tree-auto-attach-child-end"], + ["5", "tree-auto-attach-child-next-to-last-related-tab"], + ["2", "tree-auto-attach-sibling"], + ["3", "tree-auto-attach-next-sibling"], + ] + ); + addMenu( + "tree_insertNewTabFromPinnedTabAtBox", + "tree-insert-new-tab-from-pinned-tab-at-caption", + "browser.sidebar.insertNewTabFromPinnedTabAt", + [ + ["-1", "tree-insert-new-tab-from-pinned-tab-at-no-control"], + ["3", "tree-insert-new-tab-from-pinned-tab-at-next-to-last-related-tab"], + ["0", "tree-insert-new-tab-from-pinned-tab-at-top"], + ["1", "tree-insert-new-tab-from-pinned-tab-at-end"], + ] + ); + addMenu( + "tree_autoAttachOnNewTabCommandBox", + "tree-auto-attach-on-new-tab-command-caption", + "browser.sidebar.autoAttachOnNewTabCommand", + [ + ["-1", "tree-auto-attach-no-control"], + ["0", "tree-auto-attach-independent"], + ["6", "tree-auto-attach-child-top"], + ["7", "tree-auto-attach-child-end"], + ["2", "tree-auto-attach-sibling"], + ["3", "tree-auto-attach-next-sibling"], + ] + ); + addMenu( + "tree_autoAttachOnNewTabButtonMiddleClickBox", + "tree-auto-attach-on-new-tab-button-middle-click-caption", + "browser.sidebar.autoAttachOnNewTabButtonMiddleClick", + [ + ["-1", "tree-auto-attach-no-control"], + ["0", "tree-auto-attach-independent"], + ["6", "tree-auto-attach-child-top"], + ["7", "tree-auto-attach-child-end"], + ["2", "tree-auto-attach-sibling"], + ["3", "tree-auto-attach-next-sibling"], + ] + ); + addMenu( + "tree_autoAttachOnDuplicatedBox", + "tree-auto-attach-on-duplicated-caption", + "browser.sidebar.autoAttachOnDuplicated", + [ + ["-1", "tree-auto-attach-no-control"], + ["0", "tree-auto-attach-independent"], + ["6", "tree-auto-attach-child-top"], + ["7", "tree-auto-attach-child-end"], + ["2", "tree-auto-attach-sibling"], + ["3", "tree-auto-attach-next-sibling"], + ] + ); + addMenu( + "tree_autoAttachSameSiteOrphanBox", + "tree-auto-attach-same-site-orphan-caption", + "browser.sidebar.autoAttachSameSiteOrphan", + [ + ["-1", "tree-auto-attach-no-control"], + ["0", "tree-auto-attach-independent"], + ["6", "tree-auto-attach-child-top"], + ["7", "tree-auto-attach-child-end"], + ["2", "tree-auto-attach-sibling"], + ["3", "tree-auto-attach-next-sibling"], + ] + ); + addMenu( + "tree_autoAttachOnOpenedFromExternalBox", + "tree-auto-attach-on-opened-from-external-caption", + "browser.sidebar.autoAttachOnOpenedFromExternal", + [ + ["-1", "tree-auto-attach-no-control"], + ["0", "tree-auto-attach-independent"], + ["6", "tree-auto-attach-child-top"], + ["7", "tree-auto-attach-child-end"], + ["2", "tree-auto-attach-sibling"], + ["3", "tree-auto-attach-next-sibling"], + ] + ); + addMenu( + "tree_autoAttachOnAnyOtherTriggerBox", + "tree-auto-attach-on-any-other-trigger-caption", + "browser.sidebar.autoAttachOnAnyOtherTrigger", + [ + ["-1", "tree-auto-attach-no-control"], + ["0", "tree-auto-attach-independent"], + ["6", "tree-auto-attach-child-top"], + ["7", "tree-auto-attach-child-end"], + ["2", "tree-auto-attach-sibling"], + ["3", "tree-auto-attach-next-sibling"], + ] + ); + } + group.appendChild(autoAttach); + + // Insert into DOM as a top-level pane element + mainPane.appendChild(group); + + // Register preferences with the Preferences binding so widgets reflect pref values + const toRegister = [ + ["browser.sidebar.faviconizePinnedTabs", "bool"], + ["browser.sidebar.stickyActiveTab", "bool"], + ["browser.sidebar.stickySoundPlayingTab", "bool"], + ["browser.sidebar.stickySharingTab", "bool"], + ["browser.sidebar.autoCollapseExpandSubtreeOnAttach", "bool"], + ["browser.sidebar.autoCollapseExpandSubtreeOnSelect", "bool"], + ["browser.sidebar.treeDoubleClickBehavior", "unichar"], + ["browser.sidebar.successorTabControlLevel", "unichar"], + ["browser.sidebar.dropLinksOnTabBehavior", "unichar"], + ["browser.sidebar.autoAttachOnOpenedWithOwner", "unichar"], + ["browser.sidebar.insertNewTabFromPinnedTabAt", "unichar"], + ["browser.sidebar.autoAttachOnNewTabCommand", "unichar"], + ["browser.sidebar.autoAttachOnNewTabButtonMiddleClick", "unichar"], + ["browser.sidebar.autoAttachOnDuplicated", "unichar"], + ["browser.sidebar.autoAttachSameSiteOrphan", "unichar"], + ["browser.sidebar.autoAttachOnOpenedFromExternal", "unichar"], + ["browser.sidebar.autoAttachOnAnyOtherTrigger", "unichar"], + ]; + for (const [id, type] of toRegister) { + prefsWin.Preferences.add({ id, type }); + } + + // Reveal sections for our dedicated category + group.hidden = false; + appearance.hidden = false; + sticky.hidden = false; + behavior.hidden = false; + autoAttach.hidden = false; + }, +}; \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/_locales/en/messages.json b/waterfox/browser/components/sidebar/_locales/en/messages.json new file mode 100644 index 000000000000..bd9d38c5da9a --- /dev/null +++ b/waterfox/browser/components/sidebar/_locales/en/messages.json @@ -0,0 +1,1042 @@ +{ + "extensionName": { "message": "Tree Vertical Tabs" }, + "extensionDescription": { "message": "Tree Vertical Tabs for Waterfox." }, + + "sidebarTitle": { "message": "Tree Vertical Tabs" }, + "sidebarToggleDescription": { "message": "Toggle Tree Vertical Tabs" }, + + "command_tabMoveUp": { "message": "Move Current Tab Up" }, + "command_treeMoveUp": { "message": "Move Current Tree Up" }, + "command_tabMoveDown": { "message": "Move Current Tab Down" }, + "command_treeMoveDown": { "message": "Move Current Tree Down" }, + "command_focusPrevious": { "message": "Focus to Previous Tab (expand tree)" }, + "command_focusPreviousSilently": { "message": "Focus to Previous Tab (don't expand tree)" }, + "command_focusNext": { "message": "Focus to Next Tab (expand tree)" }, + "command_focusNextSilently": { "message": "Focus to Next Tab (don't expand tree)" }, + "command_focusParent": { "message": "Focus to Parent Tab" }, + "command_focusParentOrCollapse": { "message": "Collapse Tree or Focus to Parent Tab" }, + "command_focusFirstChild": { "message": "Focus to First Child Tab" }, + "command_focusFirstChildOrExpand": { "message": "Expand Tree or Focus to First Child Tab" }, + "command_focusLastChild": { "message": "Focus to Last Child Tab" }, + "command_focusPreviousSibling": { "message": "Focus to Previous Sibling Tab" }, + "command_focusNextSibling": { "message": "Focus to Next Sibling Tab" }, + "command_simulateUpOnTree": { "message": "Simulate Up Key on Tree" }, + "command_simulateDownOnTree": { "message": "Simulate Down Key on Tree" }, + "command_simulateLeftOnTree": { "message": "Simulate Left Key on Tree" }, + "command_simulateRightOnTree": { "message": "Simulate Right Key on Tree" }, + "command_tabbarUp": { "message": "Scroll Tabs Up by Lines" }, + "command_tabbarPageUp": { "message": "Scroll Tabs Up by Page" }, + "command_tabbarHome": { "message": "Scroll Tabs to Top" }, + "command_tabbarDown": { "message": "Scroll Tabs Down by Lines" }, + "command_tabbarPageDown": { "message": "Scroll Tabs Down by Page" }, + "command_tabbarEnd": { "message": "Scroll Tabs to End" }, + "command_toggleTreeCollapsed": { "message": "Toggle Tree Collapsed" }, + "command_toggleTreeCollapsedRecursively": { "message": "Toggle Tree Collapsed Recursively" }, + "command_toggleSubPanel": { "message": "Toggle Sub Panel" }, + "command_switchSubPanel": { "message": "Switch Sub Panel contents" }, + "command_increaseSubPanel": { "message": "Increase Size of the Sub Panel" }, + "command_decreaseSubPanel": { "message": "Decrease Size of the Sub Panel" }, + + "tab_closebox_aria_label": { "message": "Close tab #$ID$", + "placeholders": { + "id": { "content": "$1", "example": "1" } + }}, + "tab_closebox_tab_tooltip": { "message": "Close tab" }, + "tab_closebox_tab_tooltip_multiselected": { "message": "Close tabs" }, + "tab_closebox_tree_tooltip": { "message": "Close tree" }, + "tab_soundButton_aria_label": { "message": "Mute tab #$ID$", + "placeholders": { + "id": { "content": "$1", "example": "1" } + }}, + "tab_sharingState_sharingCamera_tooltip": { "message": "You are sharing your camera" }, + "tab_sharingState_sharingMicrophone_tooltip": { "message": "You are sharing your microphone" }, + "tab_sharingState_sharingScreen_tooltip": { "message": "You are sharing a window or a screen" }, + "tab_soundButton_muted_tooltip": { "message": "Unmute tab" }, + "tab_soundButton_muted_tooltip_multiselected": { "message": "Unmute tabs" }, + "tab_soundButton_playing_tooltip": { "message": "Mute tab" }, + "tab_soundButton_playing_tooltip_multiselected": { "message": "Mute tabs" }, + "tab_soundButton_autoplayBlocked_tooltip": { "message": "Play tab" }, + "tab_soundButton_autoplayBlocked_tooltip_multiselected": { "message": "Play tabs" }, + "tab_twisty_aria_label": { "message": "Toggle tree #$ID$", + "placeholders": { + "id": { "content": "$1", "example": "1" } + }}, + "tab_twisty_expanded_tooltip": { "message": "Collapse tree" }, + "tab_twisty_collapsed_tooltip": { "message": "Expand tree" }, + "tab_tree_tooltip": { "message": "$TREE$\n ...and $COUNT$ more tab(s)", + "placeholders": { + "tree": { "content": "$1", "example": "* Tree\n * Style\n * Tab" }, + "count": { "content": "$2", "example": "3" } + }}, + "tabbar_newTabButton_tooltip": { "message": "Open a new tab" }, + + "tabbar_newTabAction_tooltip": { "message": "Open a new tab as…" }, + "tabbar_newTabAction_independent_label": { "message": "&Independent Tab" }, + "tabbar_newTabAction_independent_command": { "message": "Independent Tab" }, + "tabbar_newTabAction_child_label": { "message": "&Child Tab" }, + "tabbar_newTabAction_child_command": { "message": "Child Tab" }, + "tabbar_newTabAction_childTop_command": { "message": "First Child Tab" }, + "tabbar_newTabAction_childEnd_command": { "message": "Last Child Tab" }, + "tabbar_newTabAction_sibling_label": { "message": "&Sibling Tab" }, + "tabbar_newTabAction_sibling_command": { "message": "Sibling Tab" }, + "tabbar_newTabAction_nextSibling_label": { "message": "&Next Sibling Tab" }, + "tabbar_newTabAction_nextSibling_command": { "message": "Next Sibling Tab" }, + + "tabbar_newTabWithContexualIdentity_tooltip": { "message": "New Container Tab" }, + "tabbar_newTabWithContexualIdentity_default": { "message": "&No Container" }, + + "groupTab_label": { "message": "$TITLE$ and more", + "placeholders": { + "title": { "content": "$1", "example": "title" } + }}, + "groupTab_label_default": { "message": "Group" }, + "groupTab_temporary_label": { "message": "Close this tab when there are no more children" }, + "groupTab_temporaryAggressive_label": { "message": "Close this tab when there is one or less child" }, + "groupTab_fromPinnedTab_label": { "message": "Tabs from $TITLE$", + "placeholders": { + "title": { "content": "$1", "example": "title" } + }}, + "groupTab_options_label": { "message": "There is an option to deactivate opening of this type tab." }, + "groupTab_options_dismiss": { "message": "Dismiss this hint" }, + + "bookmarkFolder_label_default": { "message": "%ANY(\"%GROUP%\", \"$TITLE$ and more\")% (%YEAR%.%MONTH%.%DATE%)", + "placeholders": { + "title": { "content": "$1", "example": "Title" } + }}, + + "bookmark_notification_notPermitted_title": { "message": "Permission error" }, + "bookmark_notification_notPermitted_message": { "message": "Failed to get permissions to create bookmarks. Please click here to grant extra permissions." }, + "bookmark_notification_notPermitted_message_linux": { "message": "Failed to get permissions to create bookmarks. Please click the button to grant extra permissions." }, + + "bookmarkContext_notification_notPermitted_title": { "message": "Permission error" }, + "bookmarkContext_notification_notPermitted_message": { "message": "Failed to get permissions to open a context menu for bookmarks. Please click here to grant extra permissions." }, + "bookmarkContext_notification_notPermitted_message_linux": { "message": "Failed to get permissions to open a context menu for bookmarks. Please click the button to grant extra permissions." }, + + "dropLinksOnTabBehavior_message": { "message": "How to open this?" }, + "dropLinksOnTabBehavior_save": { "message": "Do same choice hereafter" }, + "dropLinksOnTabBehavior_load": { "message": "Load to this tab" }, + "dropLinksOnTabBehavior_newtab": { "message": "Open new child tab" }, + + "tabDragBehaviorNotification_message_duration_single": { "message": "8s" }, + "tabDragBehaviorNotification_message_duration_both": { "message": "16s" }, + "tabDragBehaviorNotification_message_base": { "message": "Dropping to outside of sidebar will $RESULT$.", + "placeholders": { + "result": { "content": "$1", "example": "something happen" } + }}, + "tabDragBehaviorNotification_message_inverted_base_with_shift": { "message": "Shift-drag will $RESULT$.", + "placeholders": { + "result": { "content": "$1", "example": "something happen" } + }}, + "tabDragBehaviorNotification_message_inverted_base_without_shift": { "message": "Dragging without Shift key will $RESULT$.", + "placeholders": { + "result": { "content": "$1", "example": "something happen" } + }}, + "tabDragBehaviorNotification_message_tree_tearoff": { "message": "detach tabs from window" }, + "tabDragBehaviorNotification_message_tab_tearoff": { "message": "detach the tab from window" }, + "tabDragBehaviorNotification_message_tree_bookmark": { "message": "create links or bookmarks at the place tabs are dropped to" }, + "tabDragBehaviorNotification_message_tab_bookmark": { "message": "create a link or a bookmark at the place the tab is dropped to" }, + + "tabsHighlightingNotification_message": { "message": "Highlighting tabs... $PROGRESS$ %", + "placeholders": { + "progress": { "content": "$1", "example": "50" } + }}, + + "blank_allUrlsPermissionRequiredMessage": { "message": "This message is here due to Firefox tried to restore a dialog which is provided by Tree Style Tab extension. Please grant the \"Access your data for all websites\" permission via the \"Permissions\" tab of Tree Style Tab's details page in the Add-on Manager, if you don't want this message to appear anymore." }, + + "warnOnCloseTabs_title": { "message": "Close tabs?" }, + "warnOnCloseTabs_message": { "message": "You are about to close $NUMBER$ tabs. Are you sure you want to continue?", + "placeholders": { + "NUMBER": { "content": "$1", "example": "10" } + }}, + "warnOnCloseTabs_warnAgain": { "message": "Warn me when I attempt to close multiple tabs" }, + "warnOnCloseTabs_close": { "message": "Close tabs" }, + "warnOnCloseTabs_cancel": { "message": "Cancel" }, + + "warnOnCloseTabs_fromOutside_title": { "message": "Close with descendant tabs?" }, + "warnOnCloseTabs_fromOutside_message": { "message": "The closed tab had $NUMBER$ descendant tabs. Are you sure you want to close them also?\n(*Canceling will restore the closed parent tab.)", + "placeholders": { + "NUMBER": { "content": "$1", "example": "10" } + }}, + + "warnOnCloseTabs_notification_message": { "message": "If not, click here to cancel the operation.\nOtherwise rest tabs are closed at $TIMEOUT$ seconds later.", + "placeholders": { + "TIMEOUT": { "content": "$1", "example": "10" } + }}, + "warnOnCloseTabs_notification_message_linux": { "message": "If not, click the button to cancel the operation.\nOtherwise rest tabs are closed at $TIMEOUT$ seconds later.", + "placeholders": { + "TIMEOUT": { "content": "$1", "example": "10" } + }}, + + "warnOnAutoGroupNewTabs_title": { "message": "Group tabs?" }, + "warnOnAutoGroupNewTabs_message": { "message": "$NUMBER$ tabs are opened in a time. Do you want them to be grouped in a tree?", + "placeholders": { + "NUMBER": { "content": "$1", "example": "10" } + }}, + "warnOnAutoGroupNewTabs_warnAgain": { "message": "Ask me when multiple tabs are opened in a time" }, + "warnOnAutoGroupNewTabs_close": { "message": "Group tabs" }, + "warnOnAutoGroupNewTabs_cancel": { "message": "Keep tabs flat" }, + + "bookmarkDialog_dialogTitle_single": { "message": "New Bookmark" }, + "bookmarkDialog_dialogTitle_multiple": { "message": "New Bookmarks" }, + "bookmarkDialog_title": { "message": "Name" }, + "bookmarkDialog_title_accessKey": { "message": "n" }, + "bookmarkDialog_url": { "message": "URL" }, + "bookmarkDialog_url_accessKey": { "message": "u" }, + "bookmarkDialog_parentId": { "message": "Location" }, + "bookmarkDialog_parentId_accessKey": { "message": "l" }, + "bookmarkDialog_showAllFolders_label": { "message": "Choose…" }, + "bookmarkDialog_showAllFolders_tooltip": { "message": "Show all the bookmarks folders" }, + "bookmarkDialog_newFolder": { "message": "New Folder" }, + "bookmarkDialog_newFolder_accessKey": { "message": "o" }, + "bookmarkDialog_newFolder_defaultTitle": { "message": "New Folder" }, + "bookmarkDialog_saveContainerRedirectKey": { "message": "Save container information for Container Bookmarks" }, + "bookmarkDialog_accept": { "message": "Save" }, + "bookmarkDialog_cancel": { "message": "Cancel" }, + + "bookmarkFolderChooser_unspecified": { "message": "(unspecified)" }, + "bookmarkFolderChooser_blank": { "message": "(no name)" }, + "bookmarkFolderChooser_useThisFolder": { "message": "Use this folder" }, + + "syncDeviceDefaultName": { "message": "$BROWSER$ on $PLATFORM$", + "placeholders": { + "PLATFORM": { "content": "$1", "example": "Firefox" }, + "BROWSER": { "content": "$2", "example": "Windows" } + }}, + "syncDeviceMissingDeviceName": { "message": "an unnamed device" }, + "syncDeviceUnknownDevice": { "message": "an unknown device" }, + "syncAvailable_notification_title": { "message": "Tabs are sendable to other devices from TST now." }, + "syncAvailable_notification_message": { "message": "Tabs can be transferred between your devices. Please click here to set an identifiable name to this device." }, + "syncAvailable_notification_message_linux": { "message": "Tabs can be transferred between your devices. Please click the button to set an identifiable name to this device." }, + "sentTabs_notification_title": { "message": "\u200b" }, + "sentTabs_notification_message": { "message": "Tabs is sent to $DEVICE$", + "placeholders": { + "DEVICE": { "content": "$1", "example": "Firefox on Device X" } + }}, + "sentTabs_notification_title_multiple": { "message": "\u200b" }, + "sentTabs_notification_message_multiple": { "message": "Tabs are sent to $DEVICE$", + "placeholders": { + "DEVICE": { "content": "$1", "example": "Firefox on Device X" } + }}, + "sentTabsToAllDevices_notification_title": { "message": "\u200b" }, + "sentTabsToAllDevices_notification_message": { "message": "Tab is sent to all devices" }, + "sentTabsToAllDevices_notification_title_multiple": { "message": "\u200b" }, + "sentTabsToAllDevices_notification_message_multiple": { "message": "Tabs are sent to all devices" }, + "receiveTabs_notification_title": { "message": "Tab from $DEVICE$", + "placeholders": { + "DEVICE": { "content": "$1", "example": "Firefox on Device X" } + }}, + "receiveTabs_notification_message": { "message": "$URL$", + "placeholders": { + "URL": { "content": "$1", "example": "http://example.com/" } + }}, + "receiveTabs_notification_title_multiple": { "message": "Tabs from $DEVICE$", + "placeholders": { + "DEVICE": { "content": "$1", "example": "Firefox on Device X" } + }}, + "receiveTabs_notification_message_multiple": { "message": "$URL$\nand $REST$ more tab(s)", + "placeholders": { + "URL": { "content": "$1", "example": "http://example.com/" }, + "ALL": { "content": "$2", "example": "4" }, + "REST": { "content": "$3", "example": "3" } + }}, + + "sidebarPositionRighsideNotification_message": { "message": "TST detected the sidebar position as \"Right Side\". Do you want the appearance to be switched to the one matching to the current position?" }, + "sidebarPositionRighsideNotification_rightside": { "message": "Apply appearance for Right Side" }, + "sidebarPositionRighsideNotification_leftside": { "message": "Keep appearance for Left Side" }, + + "sidebarPositionOptionNotification_title": { "message": "Saved the appearance option for the sidebar position" }, + "sidebarPositionOptionNotification_message": { "message": "Please see the \"Appearance\" section of the Tree Vertical Tabs's options page if you need to change the option." }, + + "startup_notification_title_installed": { "message": "For newcomer of Tree Vertical Tabs" }, + "startup_notification_message_installed": { "message": "Some extra permissions are required for more natural user experiences. Please click here to grant them." }, + "startup_notification_message_installed_linux": { "message": "Some extra permissions are required for more natural user experiences. Please click the button to grant them." }, + "startup_notification_title_updated": { "message": "Noticeable Changes on Tree Vertical Tabs" }, + "startup_notification_message_updated": { "message": "Some changes may affect to your use case. Please click here to see the changelog." }, + "startup_notification_message_updated_linux": { "message": "Some changes may affect to your use case. Please click the button to see the changelog." }, + + "message_startup_description_1": { "message": "Tree Vertical Tabs's vertical tab bar is available as one of choosable sidebar panels. If you don't see it yet, hit the " }, + "message_startup_description_key": { "message": "\"F1\" key" }, + "message_startup_description_2": { "message": " or click the " }, + "message_startup_description_3": { "message": " button in the toolbar, to activate the sidebar panel." }, + "message_startup_description_sync_before": { "message": "Tabs can be sent via Firefox Sync only to devices with TST installed. Please activate Firefox Sync with Firefox's options at first, and install TST to other devices also. Moreover," }, + "message_startup_description_sync_link": { "message": "the device name should be configured manually" }, + "message_startup_description_sync_after": { "message": "." }, + + "message_startup_history_before": { "message": "Changed behaviors may be (or not) restored with workaround options. See " }, + "message_startup_history_uri": { "message": "https://addons.mozilla.org/firefox/addon/tree-style-tab/versions/" }, + "message_startup_history_link_label": { "message": "the changelog" }, + "message_startup_history_after": { "message": " for more details." }, + + "message_startup_requestPermissions_description": { "message": "⚠There are some features requiring extra permissions. If you want to activate them, please grant permissions manually." }, + "message_startup_requestPermissions_bookmarks": { "message": "Read and modify bookmarks" }, + "message_startup_requestPermissions_bookmarks_contextMenuItems": { "message": "\"Bookmark This Tree\" in the context menu on tabs" }, + "message_startup_requestPermissions_bookmarks_migrationForImportedBookmarks": { "message": "Auto migration of bookmarked internal URLs imported from other environments" }, + "message_startup_requestPermissions_bookmarks_detectTabsFromBookmarks": { "message": "Detection of tabs opened from bookmarks" }, + "message_startup_requestPermissions_allUrls": { "message": "Run script on web contents" }, + "message_startup_requestPermissions_allUrls_tabPreviewPanel": { "message": "Show tab preview image on tab hover" }, + "message_startup_requestPermissions_allUrls_skipCollapsedTabsWithCtrlTab": { "message": "Don't expand collapsed tree and skip collapsed descendants, while switching focus of tabs via keyboard shortcuts" }, + "message_startup_requestPermissions_allUrls_preventBlankDialogRestoration": { "message": "Don't restore blank window which was for a closed dialog, by actions to restore closed tabs" }, + "message_startup_requestPermissions_clipboardRead": { "message": "Read clipboard contents" }, + "message_startup_requestPermissions_clipboardRead_newTabButtonMiddleClick": { "message": "Allow to open a new tab with the URL in the clipboard, by middle click on the \"New Tab\" button (*you may need to activate \"dom.events.asyncClipboard.clipboardItem\")" }, + + + "message_startup_userChromeCss_notify": { "message": "How to hide top tab bar and sidebar header?" }, + "message_startup_userChromeCss_description_1": { "message": "Due to restrictions of WebExtensions, Tree Vertical Tabs cannot control the browser's top tab bar and the sidebar header. If you need to control them, see " }, + "message_startup_userChromeCss_description_link_label": { "message": "customization examples by userChrome.css" }, + "message_startup_userChromeCss_description_link_uri": { "message": "https://github.com/piroor/treestyletab/wiki/Code-snippets-for-custom-style-rules#for-userchromecss" }, + "message_startup_userChromeCss_description_2": { "message": ". But " }, + "message_startup_userChromeCss_description_note": { "message": "note that it is very low level customization, thus there is no guarantee and it can cause troubles on future versions of the browser. Please try it at your own risk, only when you know what you are doing and if you are confident that you can resolve such troubles by yourself" }, + "message_startup_userChromeCss_description_3": { "message": "." }, + + "api_requestedPermissions_title": { "message": "Request for Extra Permissions" }, + "api_requestedPermissions_message": { "message": "⚠The extension \"$NAME$\" is requesting to do following operation:\n\n$PERMISSIONS$\n\nvia Tree Vertical Tabs's API. Click here if you grant that.", + "placeholders": { + "NAME": { "content": "$1", "example": "foobar" }, + "PERMISSIONS": { "content": "$2", "example": "tabs" } + }}, + "api_requestedPermissions_message_linux": { "message": "⚠The extension \"$NAME$\" is requesting to do following operation:\n\n$PERMISSIONS$\n\nvia Tree Vertical Tabs's API. Click the button if you grant that.", + "placeholders": { + "NAME": { "content": "$1", "example": "foobar" }, + "PERMISSIONS": { "content": "$2", "example": "tabs" } + }}, + "api_requestedPermissions_type_activeTab": { "message": "Access active browser tab" }, + "api_requestedPermissions_type_tabs": { "message": "Access browser tabs" }, + "api_requestedPermissions_type_cookies": { "message": "Detect container of browser tabs" }, + + + "guessNewOrphanTabAsOpenedByNewTabCommandTitle": { "message": "New Tab|New Private Tab" }, + + + "context_menu_label": { "message": "Tree of tabs" }, + + "context_reloadTree_label": { "message": "&Reload this Tree" }, + "context_reloadTree_label_multiselected": { "message": "&Reload Selected Trees" }, + "context_reloadTree_command": { "message": "Reload this Tree" }, + "context_reloadDescendants_label": { "message": "R&eload Descendants" }, + "context_reloadDescendants_label_multiselected": { "message": "R&eload Descendants of Selected tabs" }, + "context_reloadDescendants_command": { "message": "Reload Descendants" }, + "context_toggleMuteTree_label_mute": { "message": "&Mute this Tree" }, + "context_toggleMuteTree_label_multiselected_mute": { "message": "&Mute Selected Trees" }, + "context_toggleMuteTree_label_unmute": { "message": "Un&mute this Tree" }, + "context_toggleMuteTree_label_multiselected_unmute": { "message": "Un&mute Selected Trees" }, + "context_toggleMuteTree_command": { "message": "Mute/Unmute this Tree" }, + "context_toggleMuteDescendants_label_mute": { "message": "Mu&te Descendants" }, + "context_toggleMuteDescendants_label_multiselected_mute": { "message": "Mu&te Descendants of Selected tabs" }, + "context_toggleMuteDescendants_label_unmute": { "message": "Unmu&te Descendants" }, + "context_toggleMuteDescendants_label_multiselected_unmute": { "message": "Unmu&te Descendants of Selected tabs" }, + "context_toggleMuteDescendants_command": { "message": "Mute/Unmute Descendants" }, + "context_unblockAutoplayTree_label": { "message": "P&lay this Tree" }, + "context_unblockAutoplayTree_label_multiselected": { "message": "P&lay Selected Trees" }, + "context_unblockAutoplayTree_command": { "message": "Play this Tree" }, + "context_unblockAutoplayDescendants_label": { "message": "Pl&ay Descendants" }, + "context_unblockAutoplayDescendants_label_multiselected": { "message": "Pl&ay Descendants of Selected tabs" }, + "context_unblockAutoplayDescendants_command": { "message": "Play Descendants" }, + "context_closeTree_label": { "message": "&Close this Tree" }, + "context_closeTree_label_multiselected": { "message": "&Close Selected Trees" }, + "context_closeTree_command": { "message": "Close this Tree" }, + "context_closeDescendants_label": { "message": "C&lose Descendants" }, + "context_closeDescendants_label_multiselected": { "message": "C&lose Descendants of Selected tabs" }, + "context_closeDescendants_command": { "message": "Close Descendants" }, + "context_closeOthers_label": { "message": "Cl&ose Other Tabs except this Tree" }, + "context_closeOthers_label_multiselected": { "message": "Cl&ose Other Tabs except Selected Trees" }, + "context_closeOthers_command": { "message": "Close Other Tabs except this Tree" }, + "context_toggleSticky_label_stick": { "message": "Stic&k Tab to Edges" }, + "context_toggleSticky_label_multiselected_stick": { "message": "Stic&k Selected Tabs to Edges" }, + "context_toggleSticky_label_unstick": { "message": "Unstic&k Tab to Edges" }, + "context_toggleSticky_label_multiselected_unstick": { "message": "Unstic&k Selected Tabs to Edges" }, + "context_toggleSticky_command": { "message": "Stick/Unstick Tab to Edges" }, + "context_collapseTree_label": { "message": "Collap&se this Tree" }, + "context_collapseTree_label_multiselected": { "message": "Collap&se Selected Trees" }, + "context_collapseTree_command": { "message": "Collapse this Tree" }, + "context_collapseTreeRecursively_label": { "message": "Co&llapse this Tree Recursively" }, + "context_collapseTreeRecursively_label_multiselected": { "message": "Co&llapse Selected Trees Recursively" }, + "context_collapseTreeRecursively_command": { "message": "Collapse this Tree Recursively" }, + "context_collapseAll_label": { "message": "Colla&pse All" }, + "context_collapseAll_command": { "message": "Collapse All" }, + "context_expandTree_label": { "message": "Exp&and this Tree" }, + "context_expandTree_label_multiselected": { "message": "Exp&and Selected Trees" }, + "context_expandTree_command": { "message": "Expand this Tree" }, + "context_expandTreeRecursively_label": { "message": "Expand this Tree Rec&ursively" }, + "context_expandTreeRecursively_label_multiselected": { "message": "Expand Selected Trees Rec&ursively" }, + "context_expandTreeRecursively_command": { "message": "Expand this Tree Recursively" }, + "context_expandAll_label": { "message": "E&xpand All" }, + "context_expandAll_command": { "message": "Expand All" }, + "context_bookmarkTree_label": { "message": "&Bookmark this Tree…" }, + "context_bookmarkTree_label_multiselected": { "message": "&Bookmark Selected Trees…" }, + "context_bookmarkTree_command": { "message": "Bookmark this Tree…" }, + "context_sendTreeToDevice_label": { "message": "Sen&d this Tree to Device" }, + "context_sendTreeToDevice_label_multiselected": { "message": "Sen&d Selected Trees to Device" }, + "context_sendTreeToDevice_command": { "message": "Send this Tree to Device" }, + "context_topLevel_prefix": { "message": "Top level item: " }, + + "context_collapsed_label": { "message": "Collapsed (for testing of a checkbox type menu)" }, + "context_pinnedTab_label": { "message": "Pinned (for testing of a radio type menu)" }, + "context_unpinnedTab_label": { "message": "Unpinned (for testing of a radio type menu)" }, + + "context_openAllBookmarksWithStructure_label": { "message": "Open &All as a Tree" }, + "context_openAllBookmarksWithStructureRecursively_label": { "message": "Open &All as a Tree including subfolders" }, + + "config_showTreeCommandsInTabsContextMenuGlobally_label": { "message": "Show commands for tree of tabs in tab context menu globally ex. on the native tab bar" }, + + + "config_title": { "message": "Tree Vertical Tabs Options" }, + + "config_recommended_choice": { "message": "(Recommended)" }, + "config_firefoxCompatible_choice": { "message": "(simulates the browser's default behavior)" }, + + "config_showExpertOptions_label": { "message": "Unlock Expert Options" }, + "config_openOptionsInTab_label": { "message": "Open this options page in more wide space" }, + + "config_appearance_caption": { "message": "Appearance" }, + + "config_sidebarPosition_caption": { "message": "Style of contents for the sidebar position:" }, + "config_sidebarPosition_left": { "message": "Left side" }, + "config_sidebarPosition_right": { "message": "Right side" }, + "config_sidebarPosition_auto": { "message": "Auto detect when the sidebar become shown" }, + "config_sidebarPosition_description": { "message": "*This configuration won't change the position of the sidebar itself. You need to click the switcher in the sidebar header and click \"Move Sidebar to Right\" manually." }, + + "config_style_caption": { "message": "Theme" }, + "config_style_proton": { "message": "Proton" }, + "config_style_photon": { "message": "Photon" }, + "config_style_sidebar": { "message": "Sidebar" }, + "config_style_highcontrast": { "message": "High Contrast" }, + "config_style_none": { "message": "No Decoration" }, + "config_style_none_info": { "message": " (*Please decorate everything by yourself via \"Advanced\" => \"User Style Sheet\")" }, + + "config_colorScheme_caption": { "message": "Color scheme:" }, + "config_colorScheme_photon": { "message": "Photon" }, + "config_colorScheme_systemColor": { "message": "System Color" }, + + "config_maxTreeLevel_before": { "message": "Indent tabs until" }, + "config_maxTreeLevel_after": { "message": "level(s) (*Negative value means \"infinite\")" }, + + "config_faviconizePinnedTabs_label": { "message": "Show pinned tabs only with their icon" }, + "config_maxFaviconizedPinnedTabsInOneRow_label_before": { "message": "Show " }, + "config_maxFaviconizedPinnedTabsInOneRow_label_after": { "message": " or less tabs for each row (*0 or less means auto-calculation)" }, + "config_maxPinnedTabsRowsAreaPercentage_label_before": { "message": "Maximum height of the area for pinned tabs:" }, + "config_maxPinnedTabsRowsAreaPercentage_label_after": { "message": "% of the sidebar" }, + "config_fadeOutPendingTabs_label": { "message": "Show icon in gray for tabs pended to load (*simulation of the browser's built-in behavior for \"browser.tabs.fadeOutUnloadedTabs\"=\"true\")" }, + "config_fadeOutDiscardedTabs_label": { "message": "Show icon in gray for tabs intentionally unloaded (*simulation of the browser's built-in behavior for \"browser.tabs.fadeOutExplicitlyUnloadedTabs\"=\"true\")" }, + "config_animation_label": { "message": "Enable animation effects" }, + "config_animationForce_label": { "message": "Enable animations regardless \"reduce animations\" platform settings" }, + "config_tabPreviewTooltip_label": { "message": "Show tab preview image on tab hover, instead of legacy tooltip (*You need to allow executing scripts on webpages)" }, + "config_tabPreviewTooltipRenderIn_label_before": { "message": "\u200b" }, + "config_tabPreviewTooltipRenderIn_content": { "message": "in the conetnt area (and show nothing if impossible)" }, + "config_tabPreviewTooltipRenderIn_sidebar": { "message": "in the sidebar always" }, + "config_tabPreviewTooltipRenderIn_anywhere": { "message": "in the conetnt area if possible, otherwise in the sidebar" }, + "config_tabPreviewTooltipRenderIn_label_after": { "message": "\u200b" }, + "config_inContentUIOffsetTop_label_before": { "message": "Shift tab preview " }, + "config_inContentUIOffsetTop_label_after": { "message": "px vertically when the height of the sidebar header cannot be detected due to privacy protection (ex. \"privacy.resistFingerprinting\"=\"true\")" }, + "config_showCollapsedDescendantsByTooltip_label": { "message": "Show collapsed descendants in the tooltip on a parent tab" }, + "config_showCollapsedDescendantsByLegacyTooltipOnSidebar_label": { "message": "and use legacy tooltip (can become wider than the sidebar) for that if needed" }, + "config_shiftTabsForScrollbarDistance_label_before": { "message": "Shift tabs aside " }, + "config_shiftTabsForScrollbarDistance_label_after": { "message": " to keep in-tab buttons touchable avoiding covered with the auto-shown scrollbar" }, + "config_shiftTabsForScrollbarDistance_placeholder": { "message": "(CSS length)" }, + "config_shiftTabsForScrollbarOnlyOnHover_label": { "message": "Shift tabs only when the mouse cursor is near the scrollbar" }, + "config_suppressGapFromShownOrHiddenToolbar_caption": { "message": "Suppress visual gap of the sidebar contents produced by temporarily shown/hidden toolbars on following cases" }, + "config_suppressGapFromShownOrHiddenToolbarOnFullScreen_label": { "message": "Fullscreen windows" }, + "config_suppressGapFromShownOrHiddenToolbarOnNewTab_label": { "message": "Blank new tabs on Firefox 85 and later" }, + "config_suppressGapFromShownOrHiddenToolbarOnlyOnMouseOperation_label": { "message": "Only when triggered by a mouse operation in the sidebar" }, + "config_showDialogInSidebar_label": { "message": "Show dialogs in the sidebar if possible" }, + "config_outOfScreenTabsRenderingPages_label": { "message": "Number of pages to pre-render tabs:" }, + "config_outOfScreenTabsRenderingPages_description": { "message": "* \"-1\" will pre-render all tabs for better scrolling performance, and as a trade off you may need to wait long time on initialization." }, + "config_renderHiddenTabs_label": { "message": "Show hidden tabs which are hidden by other extensions" }, + + + "config_context_caption": { "message": "Context Menu" }, + "config_emulateDefaultContextMenu_label": { "message": "Simulate Tab Context Menu on the sidebar" }, + "config_extraItems_tabs_caption": { "message": "Additional Tab Context Menu Items" }, + "config_extraItems_tabs_topLevel": { "message": "Top Level" }, + "config_extraItems_tabs_subMenu": { "message": "Sub Menu" }, + "config_extraItems_tabs_middleClick": { "message": "Action on Middle Click" }, + "config_extraItems_bookmarks_caption": { "message": "Additional Bookmarks Context Menu Items" }, + "config_openAllBookmarksWithStructureDiscarded_label": { "message": "Keep tabs pended until they become active" }, + "config_suppressGroupTabForStructuredTabsFromBookmarks_label": { "message": "Suppress top-level group tab when opened tabs are already organized as a tree" }, + + "config_sendTabsToDevice_caption": { "message": "Send Tabs to Other Devices with the context menu via Firefox Sync" }, + "config_syncRequirements_description": { "message": "This feature depends on Firefox Sync, and tabs can be sent only to devices with TST installed. Please activate Firefox Sync with ther browser's options at first, and install TST to other devices also." }, + "config_syncDeviceInfo_name_label": { "message": "Name and icon of this device:" }, + "config_syncDeviceInfo_name_description_before": { "message": "TST cannot detect the name of this device you configured for Firefox Sync, due to restrictions of WebExtensions API. Please set an identifiable name for this device manually. (It is recommended to copy " }, + "config_syncDeviceInfo_name_description_link": { "message": "the device name for Firefox Sync" }, + "config_syncDeviceInfo_name_description_href": { "message": "https://accounts.firefox.com/settings/clients" }, + "config_syncDeviceInfo_name_description_after": { "message": ".)" }, + "config_syncDeviceExpirationDays_label_before": { "message": "Expire unused devices automatically when they are not connected over " }, + "config_syncDeviceExpirationDays_label_after": { "message": " days (\"0\" means \"no expiration\")" }, + "config_syncUnsendableUrlPattern_label": { "message": "Matching rule to treat unsendable URL of tabs (*This must be synchronized to the \"services.sync.engine.tabs.filteredUrls\" of the browser itself):" }, + "config_otherDevices_caption": { "message": "Active other devices:" }, + "config_removeDeviceButton_label": { "message": "Delete" }, + "config_removeDeviceButton_tooltip": { "message": "Delete this device from the list" }, + + "config_autoAttachOnContextNewTabCommand_before": { "message": "For the \"New Tab\" context menu command, open new blank tab as" }, + "config_autoAttachOnContextNewTabCommand_noControl": { "message": "(no control)" }, + "config_autoAttachOnContextNewTabCommand_independent": { "message": "Independent tab" }, + "config_autoAttachOnContextNewTabCommand_childTop": { "message": "First Child of the current tab" }, + "config_autoAttachOnContextNewTabCommand_childEnd": { "message": "Last Child of the current tab" }, + "config_autoAttachOnContextNewTabCommand_sibling": { "message": "Sibling of the current tab" }, + "config_autoAttachOnContextNewTabCommand_nextSibling": { "message": "Next Sibling of the current tab" }, + "config_autoAttachOnContextNewTabCommand_nextSiblingWithInheritedContainer": { "message": "Next Sibling, same container" }, + "config_autoAttachOnContextNewTabCommand_after": { "message": "\u200b" }, + + + "config_newTabWithOwner_caption": { "message": "Tabs opened from Existing Tabs" }, + + "config_autoAttachOnOpenedWithOwner_before": { "message": "When a tab is opened from existing tab, open it as" }, + "config_autoAttachOnOpenedWithOwner_noControl": { "message": "(no control)" }, + "config_autoAttachOnOpenedWithOwner_independent": { "message": "Independent tab" }, + "config_autoAttachOnOpenedWithOwner_childNextToLastRelatedTab": { "message": "Child of the parent tab, next to the recently opened child" }, + "config_autoAttachOnOpenedWithOwner_childTop": { "message": "First Child of the parent tab" }, + "config_autoAttachOnOpenedWithOwner_childEnd": { "message": "Last Child of the parent tab" }, + "config_autoAttachOnOpenedWithOwner_sibling": { "message": "Sibling of the parent tab" }, + "config_autoAttachOnOpenedWithOwner_nextSibling": { "message": "Next Sibling of the parent tab" }, + "config_autoAttachOnOpenedWithOwner_after": { "message": "\u200b" }, + + "config_insertNewTabFromFirefoxViewAt_caption": { "message": "Insertion position of new child tabs from Firefox View (will appear as root tabs)" }, + "config_insertNewTabFromFirefoxViewAt_noControl": { "message": "No control (respect the decision by the browser or other tab extensions)" }, + "config_insertNewTabFromFirefoxViewAt_nextToLastRelatedTab": { "message": "Next to the recently opened child, or near the opener" }, + "config_insertNewTabFromFirefoxViewAt_top": { "message": "The top of the tree (near the opener)" }, + "config_insertNewTabFromFirefoxViewAt_end": { "message": "The end of the tree" }, + + "config_autoGroupNewTabsFromFirefoxView_label": { "message": "Auto-group tabs opened from Firefox View (a new tab for grouping will be opened with a title like \"Tabs from ...\")" }, + "config_groupTabTemporaryStateForChildrenOfFirefoxView_label": { "message": "Default state of group tabs for tabs opened from Firefox View:" }, + + "config_insertNewTabFromPinnedTabAt_caption": { "message": "Insertion position of new child tabs from pinned tabs (will appear as root tabs)" }, + "config_insertNewTabFromPinnedTabAt_noControl": { "message": "No control (respect the decision by the browser or other tab extensions)" }, + "config_insertNewTabFromPinnedTabAt_nextToLastRelatedTab": { "message": "Next to the recently opened child, or near the opener" }, + "config_insertNewTabFromPinnedTabAt_top": { "message": "The top of the tree (near the opener)" }, + "config_insertNewTabFromPinnedTabAt_end": { "message": "The end of the tree" }, + + "config_autoGroupNewTabsFromPinned_label": { "message": "Auto-group tabs opened from same pinned tab (a new tab for grouping will be opened with a title like \"Tabs from ...\")" }, + "config_groupTabTemporaryStateForChildrenOfPinned_label": { "message": "Default state of group tabs for tabs opened from same pinned tab:" }, + + + "config_newTab_caption": { "message": "New Tabs not from Existing Tabs" }, + + "config_newTabButton_caption": { "message": "Special actions on the \"New Tab\" button" }, + "config_longPressOnNewTabButton_before": { "message": "Long-press on the \"New Tab\" button to " }, + "config_longPressOnNewTabButton_newTabAction": { "message": "specify where the new tab is opened in" }, + "config_longPressOnNewTabButton_contextualIdentities": { "message": "choose container" }, + "config_longPressOnNewTabButton_none": { "message": "(nothing)" }, + "config_longPressOnNewTabButton_after": { "message": "\u200b" }, + "config_showNewTabActionSelector_label": { "message": "Show selector button on the \"New Tab\" button, to specify where the new tab is opened in, when the button is pointed" }, + "config_showContextualIdentitiesSelector_label": { "message": "Show container selector button on the \"New Tab\" button, when the button is pointed" }, + + "config_newTabAction_caption": { "message": "Basic control for New Blank Tabs" }, + "config_autoAttachOnNewTabCommand_before": { "message": "Open new blank tab as" }, + "config_autoAttachOnNewTabCommand_noControl": { "message": "(no control)" }, + "config_autoAttachOnNewTabCommand_independent": { "message": "Independent tab" }, + "config_autoAttachOnNewTabCommand_childTop": { "message": "First Child of the current tab" }, + "config_autoAttachOnNewTabCommand_childEnd": { "message": "Last Child of the current tab" }, + "config_autoAttachOnNewTabCommand_sibling": { "message": "Sibling of the current tab" }, + "config_autoAttachOnNewTabCommand_nextSibling": { "message": "Next Sibling of the current tab" }, + "config_autoAttachOnNewTabCommand_after": { "message": "\u200b" }, + "config_guessNewOrphanTabAsOpenedByNewTabCommandTitle_before": { "message": "Guess a newly opened tab as opened by \"New Blank Tab\" action, when it is opened with title(s) " }, + "config_guessNewOrphanTabAsOpenedByNewTabCommandUrl_before": { "message": " or URL(s) " }, + "config_guessNewOrphanTabAsOpenedByNewTabCommandUrl_after": { "message": "(*You can list multiple values with a separator \"|\")" }, + + "config_autoAttachOnNewTabButtonMiddleClick_before": { "message": "For middle click, open new blank tab as" }, + "config_autoAttachOnNewTabButtonMiddleClick_noControl": { "message": "(no control)" }, + "config_autoAttachOnNewTabButtonMiddleClick_independent": { "message": "Independent tab" }, + "config_autoAttachOnNewTabButtonMiddleClick_childTop": { "message": "First Child of the current tab" }, + "config_autoAttachOnNewTabButtonMiddleClick_childEnd": { "message": "Last Child of the current tab" }, + "config_autoAttachOnNewTabButtonMiddleClick_sibling": { "message": "Sibling of the current tab" }, + "config_autoAttachOnNewTabButtonMiddleClick_nextSibling": { "message": "Next Sibling of the current tab" }, + "config_autoAttachOnNewTabButtonMiddleClick_nextSiblingWithInheritedContainer": { "message": "Same to Ctrl-click (next sibling, same container)" }, + "config_autoAttachOnNewTabButtonMiddleClick_after": { "message": "\u200b" }, + + "config_middleClickPasteURLOnNewTabButton_label": { "message": "Open new tab with URL in the clipboard (*simulation of the browser's built-in behavior for \"browser.tabs.searchclipboardfor.middleclick\"=\"true\", and you may need to activate \"dom.events.asyncClipboard.clipboardItem\")" }, + + "config_autoAttachOnNewTabButtonAccelClick_before": { "message": "For Ctrl/⌘-click, open new blank tab as" }, + "config_autoAttachOnNewTabButtonAccelClick_noControl": { "message": "(no control)" }, + "config_autoAttachOnNewTabButtonAccelClick_independent": { "message": "Independent tab" }, + "config_autoAttachOnNewTabButtonAccelClick_childTop": { "message": "First Child of the current tab" }, + "config_autoAttachOnNewTabButtonAccelClick_childEnd": { "message": "Last Child of the current tab" }, + "config_autoAttachOnNewTabButtonAccelClick_sibling": { "message": "Sibling of the current tab" }, + "config_autoAttachOnNewTabButtonAccelClick_nextSibling": { "message": "Next Sibling of the current tab" }, + "config_autoAttachOnNewTabButtonAccelClick_nextSiblingWithInheritedContainer": { "message": "Next Sibling, same container" }, + "config_autoAttachOnNewTabButtonAccelClick_after": { "message": "\u200b" }, + + "config_autoAttachWithURL_caption": { "message": "Non-blank new tabs" }, + + "config_duplicateTabAction_caption": { "message": "Duplicate Tabs (middle click on the \"Reload\" button, etc.)" }, + + "config_autoAttachOnDuplicated_before": { "message": "Duplicate the tab as" }, + "config_autoAttachOnDuplicated_noControl": { "message": "(no control)" }, + "config_autoAttachOnDuplicated_independent": { "message": "Independent tab" }, + "config_autoAttachOnDuplicated_childTop": { "message": "First Child of the current tab" }, + "config_autoAttachOnDuplicated_childEnd": { "message": "Last Child of the current tab" }, + "config_autoAttachOnDuplicated_sibling": { "message": "Sibling of the current tab" }, + "config_autoAttachOnDuplicated_nextSibling": { "message": "Next Sibling of the current tab" }, + "config_autoAttachOnDuplicated_after": { "message": "\u200b" }, + + "config_fromExternal_caption": { "message": "New tab from Other Applications" }, + "config_autoAttachOnOpenedFromExternal_before": { "message": "Open as" }, + "config_autoAttachOnOpenedFromExternal_noControl": { "message": "(no control)" }, + "config_autoAttachOnOpenedFromExternal_independent": { "message": "Independent tab" }, + "config_autoAttachOnOpenedFromExternal_childTop": { "message": "First Child of the current tab" }, + "config_autoAttachOnOpenedFromExternal_childEnd": { "message": "Last Child of the current tab" }, + "config_autoAttachOnOpenedFromExternal_sibling": { "message": "Sibling of the current tab" }, + "config_autoAttachOnOpenedFromExternal_nextSibling": { "message": "Next Sibling of the current tab" }, + "config_autoAttachOnOpenedFromExternal_after": { "message": "\u200b" }, + "config_inheritContextualIdentityToTabsFromExternalMode_label": { "message": "Container:" }, + "config_inheritContextualIdentityToTabsFromExternalMode_default": { "message": "(no control)" }, + "config_inheritContextualIdentityToTabsFromExternalMode_parent": { "message": "Inherit from the parent tab on the tree" }, + "config_inheritContextualIdentityToTabsFromExternalMode_lastActive": { "message": "Inherit from the current tab" }, + + "config_sameSiteOrphan_caption": { "message": "New tab with the same website as the current tab from the location bar, bookmarks, histories, or other cases" }, + "config_autoAttachSameSiteOrphan_before": { "message": "Open as" }, + "config_autoAttachSameSiteOrphan_noControl": { "message": "(no control)" }, + "config_autoAttachSameSiteOrphan_independent": { "message": "Independent tab" }, + "config_autoAttachSameSiteOrphan_childTop": { "message": "First Child of the current tab" }, + "config_autoAttachSameSiteOrphan_childEnd": { "message": "Last Child of the current tab" }, + "config_autoAttachSameSiteOrphan_sibling": { "message": "Sibling of the current tab" }, + "config_autoAttachSameSiteOrphan_nextSibling": { "message": "Next Sibling of the current tab" }, + "config_autoAttachSameSiteOrphan_after": { "message": "\u200b" }, + "config_inheritContextualIdentityToSameSiteOrphanMode_label": { "message": "Container:" }, + "config_inheritContextualIdentityToSameSiteOrphanMode_default": { "message": "(no control)" }, + "config_inheritContextualIdentityToSameSiteOrphanMode_parent": { "message": "Inherit from the parent tab on the tree" }, + "config_inheritContextualIdentityToSameSiteOrphanMode_lastActive": { "message": "Inherit from the current tab" }, + + "config_anyOtherTrigger_caption": { "message": "Tabs from any other trigger" }, + "config_autoAttachOnAnyOtherTrigger_before": { "message": "Open as" }, + "config_autoAttachOnAnyOtherTrigger_noControl": { "message": "(no control)" }, + "config_autoAttachOnAnyOtherTrigger_independent": { "message": "Independent tab" }, + "config_autoAttachOnAnyOtherTrigger_childTop": { "message": "First Child of the current tab" }, + "config_autoAttachOnAnyOtherTrigger_childEnd": { "message": "Last Child of the current tab" }, + "config_autoAttachOnAnyOtherTrigger_sibling": { "message": "Sibling of the current tab" }, + "config_autoAttachOnAnyOtherTrigger_nextSibling": { "message": "Next Sibling of the current tab" }, + "config_autoAttachOnAnyOtherTrigger_after": { "message": "\u200b" }, + "config_autoAttachOnAnyOtherTrigger_caution": { "message": "*CAUTION: This option has a risk breaking behavior of other extensions." }, + "config_inheritContextualIdentityToTabsFromAnyOtherTriggerMode_label": { "message": "Container:" }, + "config_inheritContextualIdentityToTabsFromAnyOtherTriggerMode_default": { "message": "(no control)" }, + "config_inheritContextualIdentityToTabsFromAnyOtherTriggerMode_parent": { "message": "Inherit from the parent tab on the tree" }, + "config_inheritContextualIdentityToTabsFromAnyOtherTriggerMode_lastActive": { "message": "Inherit from the current tab" }, + + "config_inheritContextualIdentityToUnopenableURLTabs_label": { "message": "Reopen tab with inherited container anyway, even if it has a URL unable to open by extension's permission" }, + + "config_groupTab_caption": { "message": "Auto-grouping of tabs" }, + + "config_tabBunchesDetectionTimeout_before": { "message": "Detect tabs opened from a bookmark folder within" }, + "config_tabBunchesDetectionTimeout_after": { "message": "msec, and:" }, + "config_autoGroupNewTabsFromBookmarks_label": { "message": "Group tabs under a tab with a title like \"... and more\"" }, + "config_restoreTreeForTabsFromBookmarks_label": { "message": "Restore tree structure" }, + "config_requireBookmarksPermission_tooltiptext": { "message": "This feature needs a right to access bookmarks. Click here to request an required permission." }, + "config_requestPermissions_bookmarks_autoGroupNewTabs_before": { "message": "(" }, + "config_requestPermissions_bookmarks_autoGroupNewTabs_after": { "message": "Allow to detect bookmarks corresponding to tabs)" }, + "config_tabsFromSameFolderMinThresholdPercentage_before": { "message": "Treat all new tabs as opened from one bookmark folder, if over " }, + "config_tabsFromSameFolderMinThresholdPercentage_after": { "message": "% tabs have corresponding bookmark in common bookmark folder" }, + "config_autoGroupNewTabsFromOthers_label": { "message": "Auto-group tabs opened at a time from non-bookmark triggers also" }, + "config_tabBunchesDetectionDelayOnNewWindow_before": { "message": "But don't group new tabs opened within" }, + "config_tabBunchesDetectionDelayOnNewWindow_after": { "message": "msec when a new window is opened, as a part of tabs opened as \"home page\"." }, + "config_warnOnAutoGroupNewTabs_label": { "message": "Confirm to group tabs" }, + "config_warnOnAutoGroupNewTabsWithListing_label": { "message": "List tabs to be grouped in the confirmation dialog" }, + + "config_renderTreeInGroupTabs_label": { "message": "Render tree in group tabs" }, + + "config_groupTabTemporaryState_caption": { "message": "Default state of group tabs for each context" }, + "config_groupTabTemporaryState_option_default": { "message": "Check to nothing" }, + "config_groupTabTemporaryState_option_checked_before": { "message": "Check to the \"" }, + "config_groupTabTemporaryState_option_checked_after": { "message": "\"" }, + "config_groupTabTemporaryStateForNewTabsFromBookmarks_label": { "message": "For tabs opened from bookmarks:" }, + "config_groupTabTemporaryStateForNewTabsFromOthers_label": { "message": "For tabs opened at a time from non-bookmark triggers:" }, + "config_groupTabTemporaryStateForOrphanedTabs_label": { "message": "Opened to replace a closed parent tab:" }, + "config_groupTabTemporaryStateForAPI_label": { "message": "Opened to group tabs from other addons via API:" }, + + "config_inheritContextualIdentityToChildTabMode_label": { "message": "Container:" }, + "config_inheritContextualIdentityToChildTabMode_default": { "message": "(no control)" }, + "config_inheritContextualIdentityToChildTabMode_parent": { "message": "Inherit from the parent tab on the tree" }, + "config_inheritContextualIdentityToChildTabMode_lastActive": { "message": "Inherit from the current tab" }, + + + "config_treeBehavior_caption": { "message": "Tree Behavior" }, + + "config_successorTabControlLevel_caption": { "message": "When the current tab is closed as a last child" }, + "config_successorTabControlLevel_inTree": { "message": "Focus to the previous tab in the tree" }, + "config_successorTabControlLevel_simulateDefault": { "message": "Focus to the next tab always (the browser's default)" }, + "config_successorTabControlLevel_never": { "message": "Never control focus (respect the browser or other extension's control)" }, + "config_successorTabControlLevel_legacyDescription": { "message": "*The browser's built-in behavior for \"browser.tabs.selectOwnerOnClose\"=\"true\" works prior to this config." }, + "config_simulateSelectOwnerOnClose_label": { "message": "Move focus back to the opener tab if possible, when the current tab is closed (*simulation of the browser's built-in behavior for \"browser.tabs.selectOwnerOnClose\"=\"true\", prior to the config above)" }, + + "config_fixupTreeOnTabVisibilityChanged_caption": { "message": "When visibility of tabs are changed by other extensions" }, + "config_fixupTreeOnTabVisibilityChanged_fix": { "message": "Fix up tree structure with visible tabs automatically (*Recommended if you use another extension to switch tab groups)" }, + "config_fixupTreeOnTabVisibilityChanged_keep": { "message": "Keep tree structure including hidden tabs (*Recommended if you use another extension which changes temporary visibility of tabs)" }, + + "config_autoCollapseExpandSubtreeOnAttach_label": { "message": "When a new tree appears, collapse others automatically" }, + "config_autoCollapseExpandSubtreeOnSelect_label": { "message": "When a tab gets focus, expand its tree and collapse others automatically" }, + "config_autoCollapseExpandSubtreeOnSelectExceptActiveTabRemove_label": { "message": "Except focus moving caused by closing of the current tab" }, + "config_unfocusableCollapsedTab_label": { "message": "When a tab under a collapsed tree gets focus, expand its tree automatically" }, + "config_autoDiscardTabForUnexpectedFocus_label": { "message": "Keep tabs pending (unloaded) when tabs are accidentaly focused and the focus is redirected immediately" }, + "config_avoidDiscardedTabToBeActivatedIfPossible_label": { "message": "Avoid pending (unloaded) tabs to be activated accidentally on current tab closes or tree collapsing" }, + + "config_treeDoubleClickBehavior_caption": { "message": "Double-click on a tab" }, + "config_treeDoubleClickBehavior_toggleCollapsed": { "message": "Collapse/expand tree" }, + "config_treeDoubleClickBehavior_toggleSticky": { "message": "Stick to tab bar edges / Unstick from tab bar edges" }, + "config_treeDoubleClickBehavior_close": { "message": "Close tab" }, + "config_treeDoubleClickBehavior_close_note": { "message": " (*simulation of the browser's built-in behavior for \"browser.tabs.closeTabByDblclick\"=\"true\")" }, + "config_treeDoubleClickBehavior_none": { "message": "Do nothing" }, + + "config_parentTabOperationBehaviorMode_caption": { "message": "When a parent tab is Closed or Moved" }, + "config_parentTabOperationBehaviorMode_noteForPermanentlyConsistentBehaviors": { "message": "Whether you configured this section, there are some permanently consistent behaviors in the sidebar;\n・ Close a parent tab with Collapsed Tree: Close Entire Tree\n・ Move a parent tab with Collapsed Tree: Move Entire Tree\n・ Move a parent tab with Expanded Tree: Behave according to the section \"Drag and Drop\"" }, + + "config_parentTabOperationBehaviorMode_parallel": { "message": "Recommended preset using the browser's native tab bar as the Solo Tab Operation UI" }, + "config_closeParentBehavior_insideSidebar": { "message": "When a parent tab with Expanded Tree is Closed via the sidebar," }, + "config_closeParentBehavior_outsideSidebar": { "message": "When a parent tab (whether its Tree is Expanded or Collapsed) is Closed via the browser's native tab bar, keyboard shortcuts or other extensions," }, + "config_moveParentBehavior_outsideSidebar": { "message": "When a parent tab (whether its Tree is Expanded or Collapsed) is Moved via the browser's native tab bar, keyboard shortcuts or other extensions," }, + + "config_parentTabOperationBehaviorMode_consistent": { "message": "Recommended preset using Consistent Tab Behaviors controlled under TST" }, + "config_parentTabOperationBehaviorMode_consistent_caption": { "message": "When a parent tab with Expanded Tree is closed/moved," }, + "config_parentTabOperationBehaviorMode_consistent_notes": { "message": "Tabs behave as configured above consistently, even via the browser's native tab bar, keyboard shortcuts or other extensions." }, + + "config_parentTabOperationBehaviorMode_custom": { "message": "Custom" }, + "config_closeParentBehavior_insideSidebar_expanded_caption": { "message": "Close: When a parent tab with Expanded Tree is Closed via the sidebar," }, + "config_closeParentBehavior_outsideSidebar_collapsed_caption": { "message": "Close: When a parent tab with Collapsed Tree is Closed via the browser's native tab bar, keyboard shortcuts or other extensions," }, + "config_closeParentBehavior_outsideSidebar_expanded_caption": { "message": "Close: When a parent tab with Expanded Tree is Closed via the browser's native tab bar, keyboard shortcuts or other extensions," }, + "config_closeParentBehavior_noSidebar_collapsed_caption": { "message": "if the sidebar is closed," }, + "config_closeParentBehavior_noSidebar_expanded_caption": { "message": "if the sidebar is closed," }, + "config_moveParentBehavior_outsideSidebar_collapsed_caption": { "message": "Move: When a parent tab with Collapsed Tree is Moved via the browser's native tab bar, keyboard shortcuts or other extensions," }, + "config_moveParentBehavior_outsideSidebar_expanded_caption": { "message": "Move: When a parent tab with Expanded Tree is Moved via the browser's native tab bar, keyboard shortcuts or other extensions," }, + "config_moveParentBehavior_noSidebar_collapsed_caption": { "message": "if the sidebar is closed," }, + "config_moveParentBehavior_noSidebar_expanded_caption": { "message": "if the sidebar is closed," }, + + "config_parentTabOperationBehavior_entireTree": { "message": "Close/Move Entire Tree" }, + "config_closeParentBehavior_entireTree": { "message": "Close Entire Tree" }, + "config_moveParentBehavior_entireTree": { "message": "Move Entire Tree" }, + "config_parentTabOperationBehavior_replaceWithGroupTab": { "message": "Replace closed/moved parent with a Group Tab" }, + "config_closeParentBehavior_replaceWithGroupTab": { "message": "Replace closed parent with a Group Tab" }, + "config_moveParentBehavior_replaceWithGroupTab": { "message": "Replace moved parent with a Group Tab" }, + "config_parentTabOperationBehavior_promoteFirst": { "message": "Promote the First Child to the new parent always" }, + "config_closeParentBehavior_promoteFirst": { "message": "Promote the First Child to the new parent always" }, + "config_moveParentBehavior_promoteFirst": { "message": "Promote the First Child to the new parent always" }, + "config_parentTabOperationBehavior_promoteAll": { "message": "Promote All Children to the parent level always" }, + "config_closeParentBehavior_promoteAll": { "message": "Promote All Children to the parent level always" }, + "config_moveParentBehavior_promoteAll": { "message": "Promote All Children to the parent level always" }, + "config_parentTabOperationBehavior_promoteIntelligently": { "message": "Promote the First Child on the root level, otherwise Promote All Children" }, + "config_closeParentBehavior_promoteIntelligently": { "message": "Promote the First Child on the root level, otherwise Promote All Children" }, + "config_moveParentBehavior_promoteIntelligently": { "message": "Promote the First Child on the root level, otherwise Promote All Children" }, + "config_parentTabOperationBehavior_detach": { "message": "Liberate All Children from the tree" }, + "config_closeParentBehavior_detach": { "message": "Liberate All Children from the tree" }, + "config_moveParentBehavior_detach": { "message": "Liberate All Children from the tree" }, + + "config_warnOnCloseTabs_label": { "message": "Warn me when I attempt to close multiple tabs" }, + "config_warnOnCloseTabsByClosebox_label": { "message": "Warn me when I attempt to close multiple tabs by a normal click on a closebox" }, + "config_warnOnCloseTabsWithListing_label": { "message": "List closing tabs in the confirmation dialog" }, + + "config_insertNewChildAt_caption": { "message": "Default insertion position of new child tabs on un-controlled cases" }, + "config_insertNewChildAt_noControl": { "message": "No control (respect the decision by the browser or other tab extensions)" }, + "config_insertNewChildAt_nextToLastRelatedTab": { "message": "Next to the recently opened child, or next to the parent" }, + "config_insertNewChildAt_top": { "message": "The top of the tree (next to the parent)" }, + "config_insertNewChildAt_end": { "message": "The end of the tree" }, + + + "config_drag_caption": { "message": "Drag and Drop" }, + + "config_tabDragBehavior_caption": { "message": "When a parent tab is dragged from the sidebar" }, + "config_tabDragBehavior_description": { "message": "Due to the browser's restrictions, you need to decide it when you start dragging: how tabs are treated on dropped outside of TST's sidebar." }, + "config_tabDragBehavior_label": { "message": "Drag" }, + "config_tabDragBehaviorShift_label": { "message": "Shift-Drag" }, + "config_tabDragBehavior_label_behaviorInsideSidebar": { "message": "Behavior for Dropping Inside Sidebar" }, + "config_tabDragBehavior_label_behaviorOutsideSidebar": { "message": "Behavior for Dropping Outside Sidebar" }, + "config_tabDragBehavior_moveEntireTreeAlways": { "message": "Move Entire Tree always" }, + "config_tabDragBehavior_detachEntireTreeAlways": { "message": "Detach Entire Tree always from the window (but impossible to create bookmarks)" }, + "config_tabDragBehavior_bookmarkEntireTreeAlways": { "message": "Create links or bookmarks from Entire Tree always (but impossible to detach from the window)" }, + "config_tabDragBehavior_moveSoloTabIfExpanded": { "message": "Move Solo Tab if expanded or Entire Tree if collapsed" }, + "config_tabDragBehavior_detachSoloTabIfExpanded": { "message": "Detach Solo Tab if expanded or Entire Tree if collapsed, from the window (but impossible to create bookmark)" }, + "config_tabDragBehavior_bookmarkSoloTabIfExpanded": { "message": "Create link or bookmark from Solo Tab if expanded or Entire Tree if collapsed (but impossible to detach from the window)" }, + "config_tabDragBehavior_doNothing": { "message": "Do nothing" }, + "config_tabDragBehavior_noteForDragstartOutsideSidebar": { "message": "When a parent tabs is dragged from the browser's native tab bar, it will behave according to the section \"Tree Behavior\" => \"When a parent tab is Closed or Moved\"." }, + "config_showTabDragBehaviorNotification_label": { "message": "Show notification about what will happen on tabs dropped outside the sidebar, while tab dragging." }, + + "config_dropLinksOnTabBehavior_caption": { "message": "When a link or URL string is dropped on a tab" }, + "config_dropLinksOnTabBehavior_ask": { "message": "Always ask me how to treat it" }, + "config_dropLinksOnTabBehavior_load": { "message": "Load to the tab" }, + "config_dropLinksOnTabBehavior_newtab": { "message": "Open new child tab" }, + "config_simulateTabsLoadInBackgroundInverted_label": { "message": "When you open a dropped link in a new tab, switch to it immediately (*simulation of the browser's built-in option \"When you open a link, image or media in a new tab, switch to it immediately\" (\"browser.tabs.loadInBackground\"))" }, + "config_tabsLoadInBackgroundDiscarded_label": { "message": "Keep tabs pended until they become active" }, + + "config_insertDroppedTabsAt_caption": { "message": "Insertion position of children dropped onto a parent" }, + "config_insertDroppedTabsAt_inherit": { "message": "Treat same as new children opened from the parent" }, + "config_insertDroppedTabsAt_first": { "message": "The top of the tree (next to the parent)" }, + "config_insertDroppedTabsAt_end": { "message": "The end of the tree" }, + + "config_autoCreateFolderForBookmarksFromTree_label": { "message": "Auto-group bookmark items under a folder when they are guessed to be created from multiple tree item tabs (ex. drag and drop of multiselected tabs)" }, + "config_autoExpandOnLongHoverDelay_before": { "message": "Expand collapsed tree with hovering over " }, + "config_autoExpandOnLongHoverDelay_after": { "message": "msec while dragging" }, + "config_autoExpandOnLongHoverRestoreIniitalState_label": { "message": "Collapse such trees automatically after a drag action is finished" }, + "config_autoExpandIntelligently_label": { "message": "Collapse other trees when collapsed tree is auto-expanded" }, + "config_ignoreTabDropNearSidebarArea_label": { "message": "Don't detach tab from window if it is dropped near the sidebar area (recommended to uncheck this if you use \"privacy.resistFingerprinting\"=\"true\")" }, + + + "config_more_caption": { "message": "More..." }, + + "config_shortcuts_caption": { "message": "Keyboard shortcuts" }, + + "config_requestPermissions_allUrls_ctrlTabTracking": { "message": "Don't expand collapsed tree and skip collapsed descendants, while switching focus of tabs via keyboard shortcuts (*You need to allow executing scripts on webpages)" }, + "config_autoExpandOnTabSwitchingShortcutsDelay_before": { "message": "Expand active collapsed tree when the Tab key is pressed over " }, + "config_autoExpandOnTabSwitchingShortcutsDelay_after": { "message": "msec" }, + "config_accelKey_label": { "message": "Accelerator key for keyboard shortcuts (*You need to match this to the config \"ui.key.accelKey\"):" }, + "config_accelKey_auto": { "message": "Default (macOS=⌘, others=Ctrl)" }, + "config_accelKey_alt": { "message": "Alt" }, + "config_accelKey_control": { "message": "Ctrl/Control" }, + "config_accelKey_meta": { "message": "Meta/⌘" }, + + "config_shortcuts_resetAll": { "message": "Reset All Shortcuts" }, + + + "config_advanced_caption": { "message": "Advanced" }, + + "config_bookmarkTreeFolderName_before": { "message": "Folder name for \"Bookmark this Tree\":" }, + "config_bookmarkTreeFolderName_after": { "message": "\u200b" }, + "config_bookmarkTreeFolderName_description": { "message": "Available placeholders: %TITLE% (title of the first tab), %URL% (URL of the first tab), %YEAR% (year, four digits), %SHORT_YEAR% (year, two digits), %MONTH% (month, two digits), %DATE% (date, two digits), %HOURS% (hours, two digits), %MINUTES% (minutes, two digits), %SECONDS% (seconds, two digits), %MILLISECONDS% (milliseconds, three digits), %GROUP% (title of the first tab if it is group tab - otherwise blank), %ANY(value1, value2, ...)% (the first effective value from the given list)" }, + + "config_defaultBookmarkParentId_label": { "message": "Default location to create new bookmarks" }, + + "config_tabGroupsEnabled_label": { "message": "Activate native tab groups management (*corresponding to a built-in preference \"browser.tabs.groups.enabled\"=\"true\" of the browser itself)" }, + + "config_undoMultipleTabsClose_label": { "message": "Restore the set of tabs most recently closed, when one of them is restored via the \"Reopen Closed Tab\" command" }, + + "config_scrollLines_label_before": { "message": "Scroll up or down " }, + "config_scrollLines_label_after": { "message": "lines via \"Scroll Tabs Up/Down by Lines\" shortcut commands" }, + + "config_userStyleRules_label": { "message": "User Style Sheet (extra style rules for contents provided by Tree Vertical Tabs, ex. the sidebar)" }, + "config_userStyleRules_description_before": { "message": "For more options, see " }, + "config_userStyleRules_description_link_label": { "message": "code snippets at TST wiki" }, + "config_userStyleRules_description_after": { "message": " also." }, + "config_userStyleRules_description_link_uri": { "message": "https://github.com/piroor/treestyletab/wiki/Code-snippets-for-custom-style-rules#for-version-2x" }, + "config_userStyleRules_themeRules_description": { "message": "Following custom properties are also available via \"var()\", based on the current browser theme:" }, + "config_userStyleRules_themeRules_description_alphaVariations": { "message": "You can change the opacity of the color with a number suffix like \"--theme-colors-tab_background_text-30\" per 10% (for example, this case means 30% opacity.)" }, + "config_userStyleRules_import": { "message": "Load from File" }, + "config_userStyleRules_export": { "message": "Save as File" }, + "config_userStyleRules_overwrite_title": { "message": "Replace current style rules?" }, + "config_userStyleRules_overwrite_message": { "message": "Do you want the current style rules to be replaced with the loaded file completely?" }, + "config_userStyleRules_overwrite_overwrite": { "message": "Replace All" }, + "config_userStyleRules_overwrite_append": { "message": "Append to the last" }, + "config_tooLargeUserStyleRulesCaution": { "message": "Too long style rules! Please shorten more." }, + "config_userStyleRulesTheme_label": { "message": "Theme:" }, + "config_userStyleRulesTheme_auto": { "message": "Auto" }, + "config_userStyleRulesTheme_separator": { "message": "-----------------" }, + "config_userStyleRulesTheme_default": { "message": "Default" }, + + + "config_addons_caption": { "message": "Extra Features via Other Extensions" }, + + "config_addons_description_before": { "message": "There are " }, + "config_addons_description_link_label": { "message": "extensions which extend TST itself" }, + "config_addons_description_after": { "message": ". Check them out if you need more features on TST's sidebar." }, + "helper_addons_list_link_uri": { "message": "https://github.com/piroor/treestyletab/wiki/Helper-addons-extending-functionality-of-TST" }, + + "config_theme_description_before": { "message": "Currently there are available built-in themes only \"Plain\", \"Sidebar\" and \"High Contrast\". If you want to use non-built-in theme, see " }, + "config_theme_description_link_label": { "message": "the code snippets" }, + "config_theme_description_after": { "message": " for examples." }, + "helper_theme_list_link_uri": { "message": "https://github.com/piroor/treestyletab/wiki/Code-snippets-for-custom-style-rules#restore-old-built-in-themes" }, + + "config_externaladdonpermissions_label": { "message": "Permissions for API Call from Other Extensions" }, + "config_externaladdonpermissions_description": { "message": "Extensions allowed here may access detailed tab data or tabs in private windows, through Tree Vertical Tabs's permissions, even if the extension ifself is forbidden by the browser to access them. Please don't give permission for extensions if you cannot trust them." }, + "config_externalAddonPermissions_header_name": { "message": "Extension Name" }, + "config_externalAddonPermissions_header_permissions": { "message": "Allowed Special Operations" }, + "config_externalAddonPermissions_header_incognito": { "message": "Notify Messages from Private Windows" }, + + "addon_containerBookmarks_label": { "message": "Container Bookmarks" }, + "addon_containerBookmarks_uri": { "message": "https://addons.mozilla.org/firefox/addon/container-bookmarks/" }, + "config_containerRedirectKey_label": { "message": "Redirect Key (*Please set a value same to the one in the extension's options page):" }, + + + "config_debug_caption": { "message": "Development" }, + + "config_link_startupPage_label": { "message": "Page for Initial Startup" }, + "config_link_groupPage_label": { "message": "Page for Group Tab" }, + "config_link_tabbarPage_label": { "message": "Page for Tab Bar" }, + + "config_runTests_label": { "message": "Run Automated Tests" }, + "config_runTestsParameters_label": { "message": " / Tests to run (array of test names or regular expressions, like \"testInheritMutedState,/^testHidden/,...\"):" }, + "config_runBenchmark_label": { "message": "Run Benchmark" }, + "config_enableLinuxBehaviors_label": { "message": "Activate Linux specific behaviors" }, + "config_enableMacOSBehaviors_label": { "message": "Activate macOS specific behaviors" }, + "config_enableWindowsBehaviors_label": { "message": "Activate Windows specific behaviors" }, + "config_debug_label": { "message": "Debug mode" }, + "config_log_caption": { "message": "Detailed logging" }, + "config_logTimestamp_label": { "message": "Log with timestamp" }, + "config_logFor_common": { "message": "Logs from common modules" }, + "config_logFor_background": { "message": "Logs from background modules" }, + "config_logFor_sidebar": { "message": "Logs from sidebar modules" }, + "config_loggingQueries_label": { "message": "Log querying of tabs" }, + "config_loggingConnectionMessages_label": { "message": "Log internal messages" }, + "config_showLogsButton_label": { "message": "Show Logs" }, + "config_simulateSVGContextFill_label": { "message": "Activate workaround for the Bug 1388193 and Bug 1421329 to simulate SVG icons (*This may increase CPU usage. To deactivate this option, you need to activate \"svg.context-properties.content.enabled\" via \"about:config\", until these bugs are fixed.)" }, + "config_staticARIALabel_label": { "message": "Use static ARIA label for tab elements" }, + "config_staticARIALabel_description": { "message": "*Please try checking this if your speech recognition system misses tab elements after some tab operations." }, + "config_useCachedTree_label": { "message": "Optimize tree restoration with cache" }, + "config_useCachedTree_description": { "message": "*Please try unchecking and re-checking this to refresh the cache, when the behavior around tree is unstable." }, + "config_persistCachedTree_label": { "message": "Persist tree cache" }, + "config_persistCachedTree_description": { "message": "*Initialization process at the browser startup may be optimized, but it will broat the size of browser's session file and increase disk I/O." }, + "config_acceleratedTabCreation_label": { "message": "Accelerate operations around newly opened tabs (*NOTE: You'll see unstable behavior around tabs.)" }, + "config_maximumAcceptableDelayForTabDuplication_before": { "message": "Abort tab duplication when it takes" }, + "config_maximumAcceptableDelayForTabDuplication_after": { "message": "milliseconds or more." }, + "config_delayForDuplicatedTabDetection_label_before": { "message": "Delay to detect duplicated tabs:" }, + "config_delayForDuplicatedTabDetection_label_after": { "message": "msec (please increase this if duplicated tabs are not detected correctly)" }, + "config_delayForDuplicatedTabDetection_autoDetect": { "message": "Auto detect" }, + "config_delayForDuplicatedTabDetection_test": { "message": "Test detection" }, + "config_delayForDuplicatedTabDetection_test_resultMessage": { "message": "$PERCENTAGE$% successfully detected.", + "placeholders": { + "percentage": { "content": "$1", "example": "50" } + }}, + "config_labelOverflowStyle_caption": { "message": "Too long label of tabs:" }, + "config_labelOverflowStyle_fade": { "message": "Fade Out (Better Visibility)" }, + "config_labelOverflowStyle_crop": { "message": "Crop with \"..\" (Better Performance)" }, + + "config_requestPermissions_bookmarks": { "message": "Allow to read and create bookmarks" }, + "config_requestPermissions_bookmarks_context": { "message": "Allow to open the context menu on bookmarks" }, + "config_requestPermissions_tabHide": { "message": "Allow to show/hide individual tabs (*You must uncheck and re-check this before running tests, to ensure the permission is truly granted.)" }, + + "config_requestPermissions_fallbackToToolbarButton_title": { "message": "Click the \"Tree Vertical Tabs\" toolbar button" }, + "config_requestPermissions_fallbackToToolbarButton_message": { "message": "Due to the browser's bug, you cannot request permission from this screen. Please click the \"Tree Vertical Tabs\" toolbar button to grant permissions." }, + + "config_all_caption": { "message": "All Configs" }, + + "config_terms_delimiter": { "message": " " }, + + + "tabContextMenu_newTab_label": { "message": "Ne&w Tab" }, + "tabContextMenu_newTabNext_label": { "message": "Ne&w Tab Below" }, + "tabContextMenu_newGroup_label": { "message": "Add Tab to New &Group" }, + "tabContextMenu_newGroup_label_multiselected": { "message": "Add Tabs to New &Group" }, + "tabContextMenu_addToGroup_label": { "message": "Add Tab to &Group" }, + "tabContextMenu_addToGroup_label_multiselected": { "message": "Add Tabs to &Group" }, + "tabContextMenu_addToGroup_newGroup_label": { "message": "New &Group" }, + "tabContextMenu_addToGroup_unnamed_label": { "message": "Unnamed group" }, + "tabContextMenu_removeFromGroup_label": { "message": "&Remove from Group" }, + "tabContextMenu_removeFromGroup_label_multiselected": { "message": "&Remove from Groups" }, + + + "tabContextMenu_reload_label": { "message": "&Reload Tab" }, + "tabContextMenu_unblockAutoplay_label": { "message": "P&lay Tab" }, + "tabContextMenu_mute_label": { "message": "&Mute Tab" }, + "tabContextMenu_unmute_label": { "message": "Un&mute Tab" }, + + "tabContextMenu_pin_label": { "message": "&Pin Tab" }, + "tabContextMenu_unpin_label": { "message": "Un&pin Tab" }, + "tabContextMenu_unload_label": { "message": "&Unload Tab" }, + "tabContextMenu_duplicate_label": { "message": "&Duplicate Tab" }, + "tabContextMenu_selectAllTabs_label": { "message": "&Select All Tabs" }, + "tabContextMenu_bookmark_label": { "message": "&Bookmark Tab…" }, + "tabContextMenu_reopenInContainer_label": { "message": "Op&en in New Container Tab" }, + "tabContextMenu_reopenInContainer_noContainer_label": { "message": "&No Container" }, + "tabContextMenu_moveTab_label": { "message": "Mo&ve Tab" }, + "tabContextMenu_moveTabToStart_label": { "message": "Move to &Start" }, + "tabContextMenu_moveTabToEnd_label": { "message": "Move to &End" }, + "tabContextMenu_tearOff_label": { "message": "Move to New &Window" }, + "tabContextMenu_sendTabsToDevice_label": { "message": "Se&nd Tab to Device" }, + "tabContextMenu_sendTabsToAllDevices_label": { "message": "Send to All Devices" }, + "tabContextMenu_manageSyncDevices_label": { "message": "Manage Devices…" }, + "tabContextMenu_shareTabURL_label": { "message": "S&hare" }, + "tabContextMenu_shareTabURL_more_label": { "message": "More…" }, + + + "tabContextMenu_closeDuplicatedTabs_label": { "message": "Close D&uplicate Tabs" }, + "tabContextMenu_closeMultipleTabs_label": { "message": "Close &Multiple Tabs" }, + "tabContextMenu_closeTabsToTop_label": { "message": "Close Tabs to the &Top" }, + "tabContextMenu_closeTabsToBottom_label": { "message": "Close Tabs to the &Bottom" }, + "tabContextMenu_closeOther_label": { "message": "Cl&ose Other Tabs" }, + + "tabContextMenu_undoClose_label": { "message": "Re&open Closed Tab" }, + "tabContextMenu_undoClose_label_multiple": { "message": "Re&open Closed Tabs" }, + "tabContextMenu_close_label": { "message": "&Close Tab" }, + + "tabContextMenu_reload_label_multiselected": { "message": "&Reload Tabs" }, + "tabContextMenu_unblockAutoplay_label_multiselected": { "message": "P&lay Tab" }, + "tabContextMenu_mute_label_multiselected": { "message": "&Mute Tabs" }, + "tabContextMenu_unmute_label_multiselected": { "message": "Un&mute Tabs" }, + + "tabContextMenu_pin_label_multiselected": { "message": "&Pin Tabs" }, + "tabContextMenu_unpin_label_multiselected": { "message": "Un&pin Tabs" }, + + "tabContextMenu_unload_label_multiselected": { "message": "&Unload %S Tabs" }, + + "tabContextMenu_duplicate_label_multiselected": { "message": "&Duplicate Tabs" }, + + "tabContextMenu_moveTab_label_multiselected": { "message": "Mo&ve Tabs" }, + "tabContextMenu_sendTabsToDevice_label_multiselected": { "message": "Se&nd %S Tabs to Device" }, + + "tabContextMenu_reloadSelected_label": { "message": "&Reload Selected Tab" }, + "tabContextMenu_reloadSelected_label_multiselected": { "message": "&Reload Selected Tabs" }, + + "tabContextMenu_bookmark_label_multiselected": { "message": "&Bookmark Selected Tabs…" }, + "tabContextMenu_bookmarkSelected_label": { "message": "Bookmark Selected &Tab…" }, + "tabContextMenu_bookmarkSelected_label_multiselected": { "message": "Bookmark Selected &Tabs…" }, + + "tabContextMenu_close_label_multiselected": { "message": "&Close %S Tabs" }, + + "tabGroupMenu_tab-group-editor-title-create": { "message": "Create tab group" }, + "tabGroupMenu_tab-group-editor-title-edit": { "message": "Manage tab group" }, + "tabGroupMenu_tab-group-editor-name-label": { "message": "Name" }, + "tabGroupMenu_tab-group-editor-name-field_placeholder": { "message": "Example: Shopping" }, + "tabGroupMenu_tab-group-editor-cancel_label": { "message": "Cancel" }, + "tabGroupMenu_tab-group-editor-cancel_accesskey": { "message": "C" }, + "tabGroupMenu_tab-group-editor-color-selector_aria-label": { "message": "Tab group color" }, + "tabGroupMenu_tab-group-editor-color-selector2-blue": { "message": "Blue" }, + "tabGroupMenu_tab-group-editor-color-selector2-blue_title": { "message": "Blue" }, + "tabGroupMenu_tab-group-editor-color-selector2-purple": { "message": "Purple" }, + "tabGroupMenu_tab-group-editor-color-selector2-purple_title": { "message": "Purple" }, + "tabGroupMenu_tab-group-editor-color-selector2-cyan": { "message": "Cyan" }, + "tabGroupMenu_tab-group-editor-color-selector2-cyan_title": { "message": "Cyan" }, + "tabGroupMenu_tab-group-editor-color-selector2-orange": { "message": "Orange" }, + "tabGroupMenu_tab-group-editor-color-selector2-orange_title": { "message": "Orange" }, + "tabGroupMenu_tab-group-editor-color-selector2-yellow": { "message": "Yellow" }, + "tabGroupMenu_tab-group-editor-color-selector2-yellow_title": { "message": "Yellow" }, + "tabGroupMenu_tab-group-editor-color-selector2-pink": { "message": "Pink" }, + "tabGroupMenu_tab-group-editor-color-selector2-pink_title": { "message": "Pink" }, + "tabGroupMenu_tab-group-editor-color-selector2-green": { "message": "Green" }, + "tabGroupMenu_tab-group-editor-color-selector2-green_title": { "message": "Green" }, + "tabGroupMenu_tab-group-editor-color-selector2-gray": { "message": "Gray" }, + "tabGroupMenu_tab-group-editor-color-selector2-gray_title": { "message": "Gray" }, + "tabGroupMenu_tab-group-editor-color-selector2-red": { "message": "Red" }, + "tabGroupMenu_tab-group-editor-color-selector2-red_title": { "message": "Red" }, + "tabGroupMenu_tab-group-editor-action-new-tab_label": { "message": "New tab in group" }, + "tabGroupMenu_tab-group-editor-action-new-window_label": { "message": "Move group to new window" }, + "tabGroupMenu_tab-group-editor-action-save_label": { "message": "Save and close group" }, + "tabGroupMenu_tab-group-editor-action-ungroup_label": { "message": "Ungroup tabs" }, + "tabGroupMenu_tab-group-editor-action-delete_label": { "message": "Delete group" }, + "tabGroupMenu_tab-group-editor-done_label": { "message": "Done" }, + "tabGroupMenu_tab-group-editor-done_accesskey": { "message": "D" }, + + + "tryConfirmUsingTST_title": { "message": "Conflicting Features Detected" }, + "tryConfirmUsingTST_message": { "message": "\"Tree Style Tab\" extension has been detected: it may conflict with Waterfox's Tree Vertical Tabs. Please choose which one to enable." }, + "tryConfirmUsingTST_WS": { "message": "Waterfox's Tree Vertical Tabs" }, + "tryConfirmUsingTST_TST": { "message": "Tree Style Tab" }, + "tryConfirmUsingTST_both": { "message": "Activate Both" }, + "tryConfirmUsingTST_ask": { "message": "Confirm again when conflictiong features are detected" }, + "howToActivateAgain_title": { "message": "Tree Vertical Tabs disabled" }, + "howToActivateAgain_message": { "message": "Deactivating Waterfox's Tree Vertical Tabs. You can activate it again via the Add-ons Manager." }, + + "tabsSidebarButton_label": { "message": "Tree Vertical Tabs" }, + "tabsSidebarButton_tooltiptext": { "message": "Toggle Tree Vertical Tabs" }, + + "preferencesCategoryName": { "message": "Tree Vertical Tabs" }, + "preferencesCategoryTooltipText": { "message": "Tree Vertical Tabs" }, + + "preferences_appearanceGroup_caption": { "message": "Appearance" }, + "preferences_faviconizePinnedTabs_accesskey": { "message": "S" }, + "config_autoStickyTab_caption": { "message": "Tabs sticked to edges of the tab bar when they are scrolled out" }, + "config_stickyActiveTab_label": { "message": "Active Tab" }, + "preferences_stickyActiveTab_accesskey": { "message": "k" }, + "config_stickySoundPlayingTab_label": { "message": "Sound Playing Tabs" }, + "preferences_stickySoundPlayingTab_accesskey": { "message": "y" }, + "config_stickySharingTab_label": { "message": "Tabs Sharing Camera/Microphone/Screen" }, + "preferences_stickySharingTab_accesskey": { "message": "g" }, + + "preferences_treeBehaviorGroup_caption": { "message": "Tree view of tabs" }, + "preferences_autoCollapseExpandSubtreeOnAttach_accesskey": { "message": "c" }, + "preferences_autoCollapseExpandSubtreeOnSelect_accesskey": { "message": "x" }, + "preferences_treeDoubleClickBehavior_accesskey": { "message": "D" }, + "preferences_successorTabControlLevel_accesskey": { "message": "h" }, + "preferences_dropLinksOnTabBehavior_accesskey": { "message": "l" }, + + "preferences_autoAttachGroup_caption": { "message": "Auto-organizing of tabs tree" }, + "preferences_autoAttachOnOpenedWithOwner_accesskey": { "message": "o" }, + "preferences_insertNewTabFromPinnedTabAt_accesskey": { "message": "p" }, + "preferences_autoAttachOnNewTabCommand_accesskey": { "message": "n" }, + "preferences_autoAttachOnNewTabButtonMiddleClick_accesskey": { "message": "m" }, + "preferences_autoAttachOnDuplicated_accesskey": { "message": "u" }, + "preferences_autoAttachSameSiteOrphan_accesskey": { "message": "w" }, + "preferences_autoAttachOnOpenedFromExternal_accesskey": { "message": "A" }, + "preferences_autoAttachOnAnyOtherTrigger_accesskey": { "message": "t" } +} diff --git a/waterfox/browser/components/sidebar/_locales/ja/messages.json b/waterfox/browser/components/sidebar/_locales/ja/messages.json new file mode 100644 index 000000000000..e09437ff751f --- /dev/null +++ b/waterfox/browser/components/sidebar/_locales/ja/messages.json @@ -0,0 +1,1036 @@ +{ + "extensionName": { "message": "ツリー垂直タブ" }, + "extensionDescription": { "message": "Waterfox用のツリー垂直タブバーです。" }, + + "sidebarTitle": { "message": "ツリー垂直タブ" }, + "sidebarToggleDescription": { "message": "ツリー垂直タブの表示・非表示を切り替える" }, + + "command_tabMoveUp": { "message": "現在のタブを単体で1つ上に移動" }, + "command_treeMoveUp": { "message": "現在のタブとその配下のタブを1つ上に移動" }, + "command_tabMoveDown": { "message": "現在のタブを単体で1つ下に移動" }, + "command_treeMoveDown": { "message": "現在のタブとその配下のタブを1つ下に移動" }, + "command_focusPrevious": { "message": "前のタブにフォーカスを移す(ツリーを展開する)" }, + "command_focusPreviousSilently": { "message": "前のタブにフォーカスを移す(ツリーを展開しない)" }, + "command_focusNext": { "message": "次のタブにフォーカスを移す(ツリーを展開する)" }, + "command_focusNextSilently": { "message": "次のタブにフォーカスを移す(ツリーを展開しない)" }, + "command_focusParent": { "message": "親のタブにフォーカスを移す" }, + "command_focusParentOrCollapse": { "message": "ツリーを折りたたむか、親のタブにフォーカスを移す" }, + "command_focusFirstChild": { "message": "最初の子タブにフォーカスを移す" }, + "command_focusFirstChildOrExpand": { "message": "ツリーを展開するか、最初の子タブにフォーカスを移す" }, + "command_focusLastChild": { "message": "最後の子タブにフォーカスを移す" }, + "command_focusPreviousSibling": { "message": "同階層の前のタブにフォーカスを移す" }, + "command_focusNextSibling": { "message": "同階層の次のタブにフォーカスを移す" }, + "command_simulateUpOnTree": { "message": "ツリー上での↑キーを模倣" }, + "command_simulateDownOnTree": { "message": "ツリー上での↓キーを模倣" }, + "command_simulateLeftOnTree": { "message": "ツリー上での←キーを模倣" }, + "command_simulateRightOnTree": { "message": "ツリー上での→キーを模倣" }, + "command_tabbarUp": { "message": "タブの一覧を数行上にスクロール" }, + "command_tabbarPageUp": { "message": "タブの一覧を1ページ上にスクロール" }, + "command_tabbarHome": { "message": "タブの一覧を先頭までスクロール" }, + "command_tabbarDown": { "message": "タブの一覧を数行下にスクロール" }, + "command_tabbarPageDown": { "message": "タブの一覧を1ページ下にスクロール" }, + "command_tabbarEnd": { "message": "タブの一覧を末尾までスクロール" }, + "command_toggleTreeCollapsed": { "message": "ツリーの折りたたみ状態を切り替える" }, + "command_toggleTreeCollapsedRecursively": { "message": "ツリーの折りたたみ状態を再帰的に切り替える" }, + "command_toggleSubPanel": { "message": "サブパネルの表示・非表示を切り替える" }, + "command_switchSubPanel": { "message": "サブパネルの内容を切り替える" }, + "command_increaseSubPanel": { "message": "サブパネルの高さを広げる" }, + "command_decreaseSubPanel": { "message": "サブパネルの高さを縮める" }, + + "tab_closebox_aria_label": { "message": "タブを閉じる #$ID$", + "placeholders": { + "id": { "content": "$1", "example": "1" } + }}, + "tab_closebox_tab_tooltip": { "message": "タブを閉じる" }, + "tab_closebox_tab_tooltip_multiselected": { "message": "選択されたタブを閉じる" }, + "tab_closebox_tree_tooltip": { "message": "ツリーを閉じる" }, + "tab_sharingState_sharingCamera_tooltip": { "message": "カメラを共有しています" }, + "tab_sharingState_sharingMicrophone_tooltip": { "message": "マイクを共有しています" }, + "tab_sharingState_sharingScreen_tooltip": { "message": "ウィンドウまたは画面を共有しています" }, + "tab_soundButton_aria_label": { "message": "タブをミュート #$ID$", + "placeholders": { + "id": { "content": "$1", "example": "1" } + }}, + "tab_soundButton_muted_tooltip": { "message": "タブのミュートを解除" }, + "tab_soundButton_muted_tooltip_multiselected": { "message": "選択されたタブのミュートを解除" }, + "tab_soundButton_playing_tooltip": { "message": "タブをミュート" }, + "tab_soundButton_playing_tooltip_multiselected": { "message": "選択されたタブをミュート" }, + "tab_soundButton_autoplayBlocked_tooltip": { "message": "タブのメディアを再生" }, + "tab_soundButton_autoplayBlocked_tooltip_multiselected": { "message": "選択されたタブのメディアを再生" }, + "tab_twisty_aria_label": { "message": "ツリーを開閉 #$ID$", + "placeholders": { + "id": { "content": "$1", "example": "1" } + }}, + "tab_twisty_expanded_tooltip": { "message": "ツリーをたたむ" }, + "tab_twisty_collapsed_tooltip": { "message": "ツリーを展開する" }, + "tab_tree_tooltip": { "message": "$TREE$\n ...(省略されたタブ:$COUNT$個)", + "placeholders": { + "tree": { "content": "$1", "example": "* Tree\n * Style\n * Tab" }, + "count": { "content": "$2", "example": "3" } + }}, + "tabbar_newTabButton_tooltip": { "message": "新しいタブを開く" }, + + "tabbar_newTabAction_tooltip": { "message": "関係を指定して新しいタブを開く..." }, + "tabbar_newTabAction_independent_label": { "message": "独立したタブ(&I)" }, + "tabbar_newTabAction_independent_command": { "message": "独立したタブ" }, + "tabbar_newTabAction_child_label": { "message": "子タブ(&C)" }, + "tabbar_newTabAction_child_command": { "message": "子タブ" }, + "tabbar_newTabAction_childTop_command": { "message": "最初の子タブ" }, + "tabbar_newTabAction_childEnd_command": { "message": "最後の子タブ" }, + "tabbar_newTabAction_sibling_label": { "message": "同階層のタブ(&S)" }, + "tabbar_newTabAction_sibling_command": { "message": "同階層のタブ" }, + "tabbar_newTabAction_nextSibling_label": { "message": "同階層の隣のタブ(&N)" }, + "tabbar_newTabAction_nextSibling_command": { "message": "同階層の隣のタブ" }, + + "tabbar_newTabWithContexualIdentity_tooltip": { "message": "新しいコンテナータブ" }, + "tabbar_newTabWithContexualIdentity_default": { "message": "コンテナーなし(&N)" }, + + "groupTab_label": { "message": "$TITLE$ など", + "placeholders": { + "title": { "content": "$1", "example": "タイトル" } + }}, + "groupTab_label_default": { "message": "グループ" }, + "groupTab_temporary_label": { "message": "子タブがすべてなくなったらこのタブを閉じる" }, + "groupTab_temporaryAggressive_label": { "message": "子タブが1つ以下になったらこのタブを閉じる" }, + "groupTab_fromPinnedTab_label": { "message": "$TITLE$ から開いたタブ", + "placeholders": { + "title": { "content": "$1", "example": "title" } + }}, + "groupTab_options_label": { "message": "オプション設定でこのタブを開かないようにできます" }, + "groupTab_options_dismiss": { "message": "このヒント情報を表示しない" }, + + "bookmarkFolder_label_default": { "message": "%ANY(\"%GROUP%\", \"$TITLE$ など\")% (%YEAR%.%MONTH%.%DATE%)", + "placeholders": { + "title": { "content": "$1", "example": "タイトル" } + }}, + + "bookmark_notification_notPermitted_title": { "message": "権限のエラー" }, + "bookmark_notification_notPermitted_message": { "message": "ブックマーク作成に必要な権限を取得できませんでした。この通知をクリックして追加の権限を許可して下さい。" }, + "bookmark_notification_notPermitted_message_linux": { "message": "ブックマーク作成に必要な権限を取得できませんでした。ボタンをクリックして追加の権限を許可して下さい。" }, + + "bookmarkContext_notification_notPermitted_title": { "message": "権限のエラー" }, + "bookmarkContext_notification_notPermitted_message": { "message": "ブックマークに関するコンテキストメニューを開くのに必要な権限を取得できませんでした。この通知をクリックして追加の権限を許可して下さい。" }, + "bookmarkContext_notification_notPermitted_message_linux": { "message": "ブックマークに関するコンテキストメニューを開くのに必要な権限を取得できませんでした。ボタンをクリックして追加の権限を許可して下さい。" }, + + "dropLinksOnTabBehavior_message": { "message": "どのように開きますか?" }, + "dropLinksOnTabBehavior_save": { "message": "今後も同じ動作を行う" }, + "dropLinksOnTabBehavior_load": { "message": "このタブに読み込む" }, + "dropLinksOnTabBehavior_newtab": { "message": "新しい子タブで開く" }, + + "tabDragBehaviorNotification_message_duration_single": { "message": "8s" }, + "tabDragBehaviorNotification_message_duration_both": { "message": "16s" }, + "tabDragBehaviorNotification_message_base": { "message": "サイドバー外へのドロップで、$RESULT$。", + "placeholders": { + "result": { "content": "$1", "example": "something happen" } + }}, + "tabDragBehaviorNotification_message_inverted_base_with_shift": { "message": "Shiftキーを押しながらドラッグ開始すると、$RESULT$。", + "placeholders": { + "result": { "content": "$1", "example": "something happen" } + }}, + "tabDragBehaviorNotification_message_inverted_base_without_shift": { "message": "Shiftキーを押さずにドラッグ開始すると、$RESULT$。", + "placeholders": { + "result": { "content": "$1", "example": "something happen" } + }}, + "tabDragBehaviorNotification_message_tree_tearoff": { "message": "すべてのタブがウィンドウから切り離されます" }, + "tabDragBehaviorNotification_message_tab_tearoff": { "message": "このタブがウィンドウから切り離されます" }, + "tabDragBehaviorNotification_message_tree_bookmark": { "message": "ドロップ位置にすべてのタブからリンクまたはブックマークが作成されます" }, + "tabDragBehaviorNotification_message_tab_bookmark": { "message": "ドロップ位置にこのタブからリンクまたはブックマークが作成されます" }, + + "tabsHighlightingNotification_message": { "message": "タブを選択中... $PROGRESS$ % ", + "placeholders": { + "progress": { "content": "$1", "example": "50" } + }}, + + "blank_allUrlsPermissionRequiredMessage": { "message": "このメッセージはTree Style Tabが提供するダイアログをFirefoxが開き直そうとしたために表示されています。このメッセージを表示したくない場合は、拡張機能管理画面のTree Style Tabの詳細ページの「権限」タブで「すべてのウェブサイトの保存されたデータへのアクセス」の権限を許可してください。" }, + + "warnOnCloseTabs_title": { "message": "タブを閉じますか?" }, + "warnOnCloseTabs_message": { "message": "複数 ($NUMBER$) のタブを閉じようとしています。すべてのタブを閉じてよろしいですか?", + "placeholders": { + "NUMBER": { "content": "$1", "example": "10" } + }}, + "warnOnCloseTabs_warnAgain": { "message": "同時に複数のタブを閉じるときは確認する" }, + "warnOnCloseTabs_close": { "message": "タブを閉じる" }, + "warnOnCloseTabs_cancel": { "message": "キャンセル" }, + + "warnOnCloseTabs_fromOutside_title": { "message": "配下のタブを閉じますか?" }, + "warnOnCloseTabs_fromOutside_message": { "message": "今閉じたタブの配下には $NUMBER$ 個のタブがあります。それらもすべて閉じてよろしいですか?\n(※キャンセルすると今閉じた親タブを復元します)", + "placeholders": { + "NUMBER": { "content": "$1", "example": "10" } + }}, + + "warnOnCloseTabs_notification_message": { "message": "操作を取り消す場合はこの通知をクリックしてください。\n続行して問題がなければ、$TIMEOUT$ 秒後に残りのタブも閉じられます。", + "placeholders": { + "TIMEOUT": { "content": "$1", "example": "10" } + }}, + "warnOnCloseTabs_notification_message_linux": { "message": "操作を取り消す場合はボタンをクリックしてください。\n続行して問題がなければ、$TIMEOUT$ 秒後に残りのタブも閉じられます。", + "placeholders": { + "TIMEOUT": { "content": "$1", "example": "10" } + }}, + + "warnOnAutoGroupNewTabs_title": { "message": "タブをグループ化しますか?" }, + "warnOnAutoGroupNewTabs_message": { "message": "$NUMBER$ 個のタブが一度に開かれました。グループとして1つのツリーにまとめますか?", + "placeholders": { + "NUMBER": { "content": "$1", "example": "10" } + }}, + "warnOnAutoGroupNewTabs_warnAgain": { "message": "複数のタブが一度に開かれたときは確認する" }, + "warnOnAutoGroupNewTabs_close": { "message": "グループ化する" }, + "warnOnAutoGroupNewTabs_cancel": { "message": "そのままにする" }, + + "bookmarkDialog_dialogTitle_single": { "message": "新しいブックマーク" }, + "bookmarkDialog_dialogTitle_multiple": { "message": "新しいブックマーク" }, + "bookmarkDialog_title": { "message": "名前(N)" }, + "bookmarkDialog_title_accessKey": { "message": "n" }, + "bookmarkDialog_url": { "message": "URL" }, + "bookmarkDialog_url_accessKey": { "message": "u" }, + "bookmarkDialog_parentId": { "message": "保存場所(L)" }, + "bookmarkDialog_parentId_accessKey": { "message": "l" }, + "bookmarkDialog_showAllFolders_label": { "message": "選択..." }, + "bookmarkDialog_showAllFolders_tooltip": { "message": "すべてのブックマークフォルダーを表示します" }, + "bookmarkDialog_newFolder": { "message": "新しいフォルダー(O)" }, + "bookmarkDialog_newFolder_accessKey": { "message": "o" }, + "bookmarkDialog_newFolder_defaultTitle": { "message": "新しいフォルダー" }, + "bookmarkDialog_saveContainerRedirectKey": { "message": "コンテナーの情報をContainer Bookmarks形式で保存する" }, + "bookmarkDialog_accept": { "message": "保存" }, + "bookmarkDialog_cancel": { "message": "キャンセル" }, + + "bookmarkFolderChooser_unspecified": { "message": "(選択されていません)" }, + "bookmarkFolderChooser_blank": { "message": "(名前なし)" }, + "bookmarkFolderChooser_useThisFolder": { "message": "このフォルダーを選択" }, + + "syncDeviceDefaultName": { "message": "$PLATFORM$ の $BROWSER$", + "placeholders": { + "PLATFORM": { "content": "$1", "example": "Firefox" }, + "BROWSER": { "content": "$2", "example": "Windows" } + }}, + "syncDeviceMissingDeviceName": { "message": "名前が設定されていない端末" }, + "syncDeviceUnknownDevice": { "message": "不明な端末" }, + "syncAvailable_notification_title": { "message": "タブをTSTから他の端末に送れます" }, + "syncAvailable_notification_message": { "message": "他の端末との間でタブを送受信できます。ここをクリックして、この端末に分かりやすい名前を設定してください。" }, + "syncAvailable_notification_message_linux": { "message": "他の端末との間でタブを送受信できます。ボタンをクリックして、この端末に分かりやすい名前を設定してください。" }, + "sentTabs_notification_title": { "message": "\u200b" }, + "sentTabs_notification_message": { "message": "$DEVICE$ にタブを送信しました", + "placeholders": { + "DEVICE": { "content": "$1", "example": "Firefox on Device X" } + }}, + "sentTabs_notification_title_multiple": { "message": "\u200b" }, + "sentTabs_notification_message_multiple": { "message": "$DEVICE$ にタブを送信しました", + "placeholders": { + "DEVICE": { "content": "$1", "example": "Firefox on Device X" } + }}, + "sentTabsToAllDevices_notification_title": { "message": "\u200b" }, + "sentTabsToAllDevices_notification_message": { "message": "すべての端末にタブを送信しました" }, + "sentTabsToAllDevices_notification_title_multiple": { "message": "\u200b" }, + "sentTabsToAllDevices_notification_message_multiple": { "message": "すべての端末にタブを送信しました" }, + "receiveTabs_notification_title": { "message": "$DEVICE$ からのタブ", + "placeholders": { + "DEVICE": { "content": "$1", "example": "Firefox on Device X" } + }}, + "receiveTabs_notification_message": { "message": "$URL$", + "placeholders": { + "URL": { "content": "$1", "example": "http://example.com/" } + }}, + "receiveTabs_notification_title_multiple": { "message": "$DEVICE$ からのタブ", + "placeholders": { + "DEVICE": { "content": "$1", "example": "Firefox on Device X" } + }}, + "receiveTabs_notification_message_multiple": { "message": "$URL$\nを含む $ALL$ 個のタブ", + "placeholders": { + "URL": { "content": "$1", "example": "http://example.com/" }, + "ALL": { "content": "$2", "example": "4" }, + "REST": { "content": "$3", "example": "3" } + }}, + + "sidebarPositionRighsideNotification_message": { "message": "サイドバーが右側に置かれていることを検出しました。現在の位置に合わせて表示を切り替えますか?" }, + "sidebarPositionRighsideNotification_rightside": { "message": "右側用の表示に切り替える" }, + "sidebarPositionRighsideNotification_leftside": { "message": "左側用の表示を維持する" }, + + "sidebarPositionOptionNotification_title": { "message": "サイドバーの内容の表示設定を保存しました" }, + "sidebarPositionOptionNotification_message": { "message": "サイドバーの内容の表示を変更するには、Tabs Sidebarの設定画面の「外観」を参照して下さい" }, + + "startup_notification_title_installed": { "message": "Tabs Sidebarを使い始める方へ" }, + "startup_notification_message_installed": { "message": "より自然な操作感を得るためには、追加の権限を許可する必要があります。ここをクリックして権限を設定してください。" }, + "startup_notification_message_installed_linux": { "message": "より自然な操作感を得るためには、追加の権限を許可する必要があります。ボタンをクリックして権限を設定してください。" }, + "startup_notification_title_updated": { "message": "Tabs Sidebarの動作が変わりました" }, + "startup_notification_message_updated": { "message": "操作感に影響する大きな変更がありました。ここをクリックして変更履歴を確認してください。" }, + "startup_notification_message_updated_linux": { "message": "操作感に影響する大きな変更がありました。ボタンをクリックして変更履歴を確認してください。" }, + + "message_startup_description_1": { "message": "Tabs Sidebarの縦型タブバーは切り替え可能なサイドバーパネルの一つになっています。もし表示されていない場合は、" }, + "message_startup_description_key": { "message": "「F1」キー" }, + "message_startup_description_2": { "message": "を押すかツールバー上の「" }, + "message_startup_description_3": { "message": "」ボタンをクリックして「ツリー型タブ」サイドバーパネルを表示して下さい。" }, + "message_startup_description_sync_before": { "message": "Firefox Sync経由でのタブの他の端末への送信は、TSTが動作している端末に対してのみ行えます。事前にブラウザーの設定画面からFirefox Syncを有効化しておき、他の端末でもTSTを有効化しておいてください。また、" }, + "message_startup_description_sync_link": { "message": "端末名は手動で設定する必要があります" }, + "message_startup_description_sync_after": { "message": "。" }, + + "message_startup_history_before": { "message": "更新によって変更された動作は、設定で元の動作に戻せる場合があります。詳細は" }, + "message_startup_history_uri": { "message": "https://addons.mozilla.org/firefox/addon/tree-style-tab/versions/" }, + "message_startup_history_link_label": { "message": "変更履歴" }, + "message_startup_history_after": { "message": "を参照してください。" }, + + "message_startup_requestPermissions_description": { "message": "⚠以下の機能には追加の権限が必要です。機能を使いたい場合のみ権限を付与して下さい。" }, + "message_startup_requestPermissions_bookmarks": { "message": "ブックマークの操作を許可する" }, + "message_startup_requestPermissions_bookmarks_contextMenuItems": { "message": "コンテキストメニューの「このツリーをブックマーク」機能" }, + "message_startup_requestPermissions_bookmarks_migrationForImportedBookmarks": { "message": "他の環境からインポートされたブックマークの内部URLの自動移行" }, + "message_startup_requestPermissions_bookmarks_detectTabsFromBookmarks": { "message": "ブックマークから開かれたタブの検出" }, + "message_startup_requestPermissions_allUrls": { "message": "コンテンツ領域内のWebページ上でのスクリプトの実行" }, + "message_startup_requestPermissions_allUrls_tabPreviewPanel": { "message": "ツールチップの代わりにタブのプレビュー画像を表示する" }, + "message_startup_requestPermissions_allUrls_skipCollapsedTabsWithCtrlTab": { "message": "キーボードショートカットでのタブ切り替え中、折りたたまれた子孫タブにはフォーカスせず、折りたたまれたツリーも展開しない" }, + "message_startup_requestPermissions_allUrls_preventBlankDialogRestoration": { "message": "タブを開きなおす操作でダイアログ用の空のウィンドウが復元されないようにする" }, + "message_startup_requestPermissions_clipboardRead": { "message": "クリップボードの読み取り" }, + "message_startup_requestPermissions_clipboardRead_newTabButtonMiddleClick": { "message": "「新しいタブ」ボタンの中ボタンクリックでクリップボード内のURLを開く(※\"dom.events.asyncClipboard.clipboardItem\"の有効化が必要)" }, + + "message_startup_userChromeCss_notify": { "message": "タブバーとサイドバーのヘッダーを隠したい場合" }, + "message_startup_userChromeCss_description_1": { "message": "WebExtensionsの仕様上、ブラウザーのタブバーとサイドバーのヘッダーはTabs Sidebarから制御できません。これらを隠したい場合は" }, + "message_startup_userChromeCss_description_link_label": { "message": "userChrome.cssでのカスタマイズ例" }, + "message_startup_userChromeCss_description_link_uri": { "message": "https://github.com/piroor/treestyletab/wiki/Code-snippets-for-custom-style-rules#for-userchromecss" }, + "message_startup_userChromeCss_description_2": { "message": "を参照して下さい。ただし、" }, + "message_startup_userChromeCss_description_note": { "message": "この方法は低レイヤのカスタマイズ手段のため、ブラウザーの将来のバージョンで使えなくなったり、トラブルの原因になったりする恐れがあります。自己の責任と判断に基づいて、トラブルを自己解決できる自信がある場合にだけこの方法を使って下さい。" }, + "message_startup_userChromeCss_description_3": { "message": "\u200b" }, + + "api_requestedPermissions_title": { "message": "アクセス権の要求" }, + "api_requestedPermissions_message": { "message": "⚠拡張機能「$NAME$」がTabs SidebarのAPIを経由して以下のことをしようとしています。\n\n$PERMISSIONS$\n\n許可する場合はこの通知をクリックして拡張機能に許可を与えてください。", + "placeholders": { + "NAME": { "content": "$1", "example": "foobar" }, + "PERMISSIONS": { "content": "$2", "example": "tabs" } + }}, + "api_requestedPermissions_message_linux": { "message": "⚠拡張機能「$NAME$」がTabs SidebarのAPIを経由して以下のことをしようとしています。\n\n$PERMISSIONS$\n\n許可する場合はボタンをクリックして拡張機能に許可を与えてください。", + "placeholders": { + "NAME": { "content": "$1", "example": "foobar" }, + "PERMISSIONS": { "content": "$2", "example": "tabs" } + }}, + "api_requestedPermissions_type_activeTab": { "message": "ブラウザーの現在のタブへのアクセス" }, + "api_requestedPermissions_type_tabs": { "message": "ブラウザーのタブへのアクセス" }, + "api_requestedPermissions_type_cookies": { "message": "ブラウザーのタブのコンテナーの識別" }, + + + "guessNewOrphanTabAsOpenedByNewTabCommandTitle": { "message": "新しいタブ|新しいプライベートタブ" }, + + + "context_menu_label": { "message": "タブのツリーを操作" }, + + "context_reloadTree_label": { "message": "このツリーを再読み込み(&R)" }, + "context_reloadTree_label_multiselected": { "message": "選択したツリーを再読み込み(&R)" }, + "context_toggleMuteTree_label_mute": { "message": "このツリーをミュート(&M)" }, + "context_toggleMuteTree_label_multiselected_mute": { "message": "選択したツリーをミュート(&M)" }, + "context_toggleMuteTree_label_unmute": { "message": "このツリーのミュートを解除(&M)" }, + "context_toggleMuteTree_label_multiselected_unmute": { "message": "選択したツリーのミュートを解除(&M)" }, + "context_toggleMuteTree_command": { "message": "このツリーをミュート/ミュート解除" }, + "context_toggleMuteDescendants_label_mute": { "message": "このタブの配下のタブをすべてミュート(&T)" }, + "context_toggleMuteDescendants_label_multiselected_mute": { "message": "選択したタブの配下のタブをすべてミュート(&T)" }, + "context_toggleMuteDescendants_label_unmute": { "message": "このタブの配下のタブをすべてミュート解除(&T)" }, + "context_toggleMuteDescendants_label_multiselected_unmute": { "message": "選択したタブの配下のタブをすべてミュート解除(&T)" }, + "context_toggleMuteDescendants_command": { "message": "このタブ配下のタブをすべてミュート/ミュート解除" }, + "context_unblockAutoplayTree_label": { "message": "このツリーのメディアを再生(&L)" }, + "context_unblockAutoplayTree_label_multiselected": { "message": "選択したツリーのメディアを再生(&L)" }, + "context_unblockAutoplayTree_command": { "message": "このツリーのメディアを再生" }, + "context_unblockAutoplayDescendants_label": { "message": "このタブ配下のタブのメディアを再生(&A)" }, + "context_unblockAutoplayDescendants_label_multiselected": { "message": "選択したタブの配下のタブのメディアを再生(&A)" }, + "context_unblockAutoplayDescendants_command": { "message": "このタブ配下のタブのメディアを再生" }, + "context_reloadTree_command": { "message": "このツリーを再読み込み" }, + "context_reloadDescendants_label": { "message": "このタブの配下のタブをすべて再読み込み(&E)" }, + "context_reloadDescendants_label_multiselected": { "message": "選択したタブの配下のタブをすべて再読み込み(&E)" }, + "context_reloadDescendants_command": { "message": "このタブの配下のタブをすべて再読み込み" }, + "context_closeTree_label": { "message": "このツリーを閉じる(&C)" }, + "context_closeTree_label_multiselected": { "message": "選択したツリーを閉じる(&C)" }, + "context_closeTree_command": { "message": "このツリーを閉じる" }, + "context_closeDescendants_label": { "message": "このタブの配下のタブをすべて閉じる(&L)" }, + "context_closeDescendants_label_multiselected": { "message": "選択したタブの配下のタブをすべて閉じる(&L)" }, + "context_closeDescendants_command": { "message": "このタブの配下のタブをすべて閉じる" }, + "context_closeOthers_label": { "message": "このツリー以外の他のタブをすべて閉じる(&O)" }, + "context_closeOthers_label_multiselected": { "message": "選択したツリー以外の他のタブをすべて閉じる(&O)" }, + "context_closeOthers_command": { "message": "このツリー以外の他のタブをすべて閉じる" }, + "context_toggleSticky_label_stick": { "message": "タブを端に貼り付け(&K)" }, + "context_toggleSticky_label_multiselected_stick": { "message": "選択したタブを端に貼り付け(&K)" }, + "context_toggleSticky_label_unstick": { "message": "タブの貼り付けを解除(&K)" }, + "context_toggleSticky_label_multiselected_unstick": { "message": "選択したタブの貼り付けを解除(&K)" }, + "context_toggleSticky_command": { "message": "タブを端に貼り付け/タブの貼り付けを解除" }, + "context_collapseTree_label": { "message": "このツリーをたたむ(&S)" }, + "context_collapseTree_label_multiselected": { "message": "選択したツリーをたたむ(&S)" }, + "context_collapseTree_command": { "message": "このツリーをたたむ" }, + "context_collapseTreeRecursively_label": { "message": "このツリーを再帰的にたたむ(&L)" }, + "context_collapseTreeRecursively_label_multiselected": { "message": "選択したツリーを再帰的にたたむ(&L)" }, + "context_collapseTreeRecursively_command": { "message": "このツリーを再帰的にたたむ" }, + "context_collapseAll_label": { "message": "すべてのツリーをたたむ(&P)" }, + "context_collapseAll_command": { "message": "すべてのツリーをたたむ" }, + "context_expandTree_label": { "message": "このツリーを展開(&A)" }, + "context_expandTree_label_multiselected": { "message": "選択したツリーを展開(&A)" }, + "context_expandTree_command": { "message": "このツリーを展開" }, + "context_expandTreeRecursively_label": { "message": "このツリーを再帰的に展開(&U)" }, + "context_expandTreeRecursively_label_multiselected": { "message": "選択したツリーを再帰的に展開(&U)" }, + "context_expandTreeRecursively_command": { "message": "このツリーを再帰的に展開" }, + "context_expandAll_label": { "message": "すべてのツリーを展開(&X)" }, + "context_expandAll_command": { "message": "すべてのツリーを展開" }, + "context_bookmarkTree_label": { "message": "このツリーをブックマーク...(&B)" }, + "context_bookmarkTree_label_multiselected": { "message": "選択したツリーをブックマーク...(&B)" }, + "context_bookmarkTree_command": { "message": "このツリーをブックマーク..." }, + "context_sendTreeToDevice_label": { "message": "このツリーを端末に送信(&D)" }, + "context_sendTreeToDevice_label_multiselected": { "message": "選択したツリーを端末に送信(&D)" }, + "context_sendTreeToDevice_command": { "message": "このツリーを端末に送信" }, + "context_topLevel_prefix": { "message": "トップレベルの項目: " }, + + "context_openAllBookmarksWithStructure_label": { "message": "ツリーとしてすべて開く(&A)" }, + "context_openAllBookmarksWithStructureRecursively_label": { "message": "サブフォルダーも含めてツリーとしてすべて開く(&A)" }, + + "config_showTreeCommandsInTabsContextMenuGlobally_label": { "message": "ネイティブのタブバー上など、タブのコンテキストメニュー全般にツリー操作用のコマンドを表示する" }, + + + + "config_title": { "message": "Tabs Sidebarの設定" }, + + "config_recommended_choice": { "message": "(推奨)" }, + "config_firefoxCompatible_choice": { "message": "(ブラウザー既定の挙動の再現)" }, + + "config_showExpertOptions_label": { "message": "上級者向けの設定をアンロック" }, + "config_openOptionsInTab_label": { "message": "この設定画面をより広いスペースで開く" }, + + "config_appearance_caption": { "message": "外観" }, + + "config_sidebarPosition_caption": { "message": "サイドバーの位置に合わせたスタイル:" }, + "config_sidebarPosition_left": { "message": "左側用" }, + "config_sidebarPosition_right": { "message": "右側用" }, + "config_sidebarPosition_auto": { "message": "サイドバーが表示された時に自動で判定" }, + "config_sidebarPosition_description": { "message": "※この設定はサイドバー自体の位置を変更しません。サイドバーをウィンドウの右側に移動するには、サイドバーのヘッダ上の切り替えメニューから「サイドバーを右側に移動」を選択して下さい。" }, + + "config_style_caption": { "message": "テーマ" }, + "config_style_proton": { "message": "Proton" }, + "config_style_photon": { "message": "Photon" }, + "config_style_sidebar": { "message": "Sidebar" }, + "config_style_highcontrast": { "message": "ハイコントラスト" }, + "config_style_none": { "message": "装飾無し" }, + "config_style_none_info": { "message": "(※「詳細設定」→「ユーザースタイルシート」でお好みの装飾を設定してください)" }, + + "config_colorScheme_caption": { "message": "配色:" }, + "config_colorScheme_photon": { "message": "Photon" }, + "config_colorScheme_systemColor": { "message": "システムカラー" }, + + "config_maxTreeLevel_before": { "message": "ツリーを" }, + "config_maxTreeLevel_after": { "message": "階層までインデント表示する(※負の値を指定した場合は無制限)" }, + + "config_faviconizePinnedTabs_label": { "message": "ピン留めされたタブはアイコンのみ表示する" }, + "config_maxFaviconizedPinnedTabsInOneRow_label_before": { "message": "一行に" }, + "config_maxFaviconizedPinnedTabsInOneRow_label_after": { "message": "個までタブを表示(※0以下で自動折り返し)" }, + "config_maxPinnedTabsRowsAreaPercentage_label_before": { "message": "ピン留めされたタブを表示する領域の最大の高さ:サイドバーの" }, + "config_maxPinnedTabsRowsAreaPercentage_label_after": { "message": "%まで" }, + "config_fadeOutPendingTabs_label": { "message": "読み込みを保留中のタブのアイコンを灰色で表示(※ブラウザー組み込みの機能である\"browser.tabs.fadeOutUnloadedTabs\"=\"true\"の再現)" }, + "config_fadeOutDiscardedTabs_label": { "message": "意図的に解放した田夫のアイコンを灰色で表示(※ブラウザー組み込みの機能である\"browser.tabs.fadeOutExplicitlyUnloadedTabs\"=\"true\"の再現)" }, + "config_animation_label": { "message": "アニメーション効果を有効にする" }, + "config_animationForce_label": { "message": "アニメーションを抑制するプラットフォームの設定を無視して有効にする" }, + "config_tabPreviewTooltip_label": { "message": "タブの上にカーソルを乗せたとき、ツールチップの代わりにタブのプレビュー画像を表示する(※Webページ内でのスクリプトの実行を許可する必要があります)" }, + "config_tabPreviewTooltipRenderIn_label_before": { "message": "表示先:" }, + "config_tabPreviewTooltipRenderIn_content": { "message": "コンテンツ領域内(不可能な場合は何も表示しない)" }, + "config_tabPreviewTooltipRenderIn_sidebar": { "message": "常にサイドバー内" }, + "config_tabPreviewTooltipRenderIn_anywhere": { "message": "可能な場合はコンテンツ領域内、それ以外の場合はサイドバー内" }, + "config_tabPreviewTooltipRenderIn_label_after": { "message": "\u200b" }, + "config_inContentUIOffsetTop_label_before": { "message": "プライバシー優先時(\"privacy.resistFingerprinting\"=\"true\"に設定している場合など)でサイドバーのヘッダーの高さを検出できない場合に、プレビューの表示位置を縦方向に" }, + "config_inContentUIOffsetTop_label_after": { "message": "ピクセルずらす" }, + "config_showCollapsedDescendantsByTooltip_label": { "message": "親タブ上のツールチップで、折りたたまれた子孫タブの一覧を表示する" }, + "config_showCollapsedDescendantsByLegacyTooltipOnSidebar_label": { "message": "その際は必要に応じてネイティブのツールチップ(サイドバーの幅より広く表示できる)を使用する" }, + "config_shiftTabsForScrollbarDistance_label_before": { "message": "自動的に表示されるスクロールバーに覆われてタブ内のボタンに触れなくならないよう、" }, + "config_shiftTabsForScrollbarDistance_label_after": { "message": "ぶんだけタブをずらして表示する" }, + "config_shiftTabsForScrollbarDistance_placeholder": { "message": "(CSS長さ)" }, + "config_shiftTabsForScrollbarOnlyOnHover_label": { "message": "スクロールバーにマウスカーソルが近付いた時だけタブをずらす" }, + "config_suppressGapFromShownOrHiddenToolbar_caption": { "message": "以下の場面でツールバーの表示・非表示が一時的に切り替わった場合に、サイドバーの内容をずらして視覚的なガタつきを抑制する" }, + "config_suppressGapFromShownOrHiddenToolbarOnFullScreen_label": { "message": "フルスクリーンモードのウィンドウ" }, + "config_suppressGapFromShownOrHiddenToolbarOnNewTab_label": { "message": "Firefox 85以降での新しい空のタブ" }, + "config_suppressGapFromShownOrHiddenToolbarOnlyOnMouseOperation_label": { "message": "サイドバーをマウスで操作した場合のみ抑制する" }, + "config_showDialogInSidebar_label": { "message": "可能であればサイドバー内でダイアログを表示する" }, + "config_outOfScreenTabsRenderingPages_label": { "message": "タブを事前にレンダリングしておくページ数:" }, + "config_outOfScreenTabsRenderingPages_description": { "message": "※「-1」を指定するとすべてのタブを事前にレンダリングするようになり、スクロールの応答性が向上しますが、引き替えとして、初期化処理に長い時間を要するようになります" }, + "config_renderHiddenTabs_label": { "message": "他の拡張機能が隠したタブを表示する" }, + + + "config_context_caption": { "message": "コンテキストメニュー" }, + "config_emulateDefaultContextMenu_label": { "message": "サイドバー上でタブのコンテキストメニューを再現する" }, + "config_extraItems_tabs_caption": { "message": "タブのコンテキストメニューに追加する項目" }, + "config_extraItems_tabs_topLevel": { "message": "トップレベル" }, + "config_extraItems_tabs_subMenu": { "message": "サブメニュー" }, + "config_extraItems_tabs_middleClick": { "message": "ミドルクリック時の動作" }, + "config_extraItems_bookmarks_caption": { "message": "ブックマークのコンテキストメニューに追加する項目" }, + "config_openAllBookmarksWithStructureDiscarded_label": { "message": "読み込みを保留した状態でタブを開く" }, + "config_suppressGroupTabForStructuredTabsFromBookmarks_label": { "message": "1つのツリーにまとまった状態でタブが開かれる場合、最上位のグループ化用のタブを開くことを抑制する" }, + + "config_sendTabsToDevice_caption": { "message": "Firefox Syncによる、コンテキストメニューからの他の端末へのタブ送信" }, + "config_syncRequirements_description": { "message": "この機能はFirefox Syncに依存し、TSTが動作している端末に対してのみタブを送信できます。事前にブラウザーの設定画面からFirefox Syncを有効化しておき、他の端末でもTSTを有効化しておいてください。" }, + "config_syncDeviceInfo_name_label": { "message": "この端末の表示名とアイコン:" }, + "config_syncDeviceInfo_name_description_before": { "message": "WebExtensions APIの制約により、TSTはFirefox Syncで設定済みの端末名を自動認識しません。分かりやすい端末名を手動で設定してください。(" }, + "config_syncDeviceInfo_name_description_link": { "message": "Firefox Syncと同じ端末名" }, + "config_syncDeviceInfo_name_description_href": { "message": "https://accounts.firefox.com/settings/clients" }, + "config_syncDeviceInfo_name_description_after": { "message": "に設定することを推奨します)" }, + "config_syncDeviceExpirationDays_label_before": { "message": "\u200b" }, + "config_syncDeviceExpirationDays_label_after": { "message": "日以上接続接続していない端末は自動的に端末一覧から削除する(\"0\"=自動削除しない)" }, + "config_syncUnsendableUrlPattern_label": { "message": "送信できないタブを識別するためのマッチングルール(※ブラウザー自身の \"services.sync.engine.tabs.filteredUrls\" と同じ内容を設定してください)" }, + "config_otherDevices_caption": { "message": "使用可能な他の端末:" }, + "config_removeDeviceButton_label": { "message": "削除" }, + "config_removeDeviceButton_tooltip": { "message": "この端末を一覧から削除する" }, + + "config_autoAttachOnContextNewTabCommand_before": { "message": "コンテキストメニューの「新しいタブ」コマンドで、新しい空のタブを" }, + "config_autoAttachOnContextNewTabCommand_noControl": { "message": "(制御せず既定の位置に)" }, + "config_autoAttachOnContextNewTabCommand_independent": { "message": "独立したタブとして" }, + "config_autoAttachOnContextNewTabCommand_childTop": { "message": "現在のタブの最初の子として" }, + "config_autoAttachOnContextNewTabCommand_childEnd": { "message": "現在のタブの最後の子として" }, + "config_autoAttachOnContextNewTabCommand_sibling": { "message": "現在のタブと同階層に" }, + "config_autoAttachOnContextNewTabCommand_nextSibling": { "message": "現在のタブと同階層で隣に" }, + "config_autoAttachOnContextNewTabCommand_nextSiblingWithInheritedContainer": { "message": "Ctrl-クリックと同様に(同階層で隣に、同じコンテナーで)" }, + "config_autoAttachOnContextNewTabCommand_after": { "message": "開く" }, + + + "config_newTabWithOwner_caption": { "message": "既存のタブから開かれたタブ" }, + + "config_autoAttachOnOpenedWithOwner_before": { "message": "既存のタブからタブが開かれた場合、" }, + "config_autoAttachOnOpenedWithOwner_noControl": { "message": "(制御せず既定の位置に)" }, + "config_autoAttachOnOpenedWithOwner_independent": { "message": "独立したタブとして" }, + "config_autoAttachOnOpenedWithOwner_childNextToLastRelatedTab": { "message": "親のタブの子として、直近に開いた子タブの隣に" }, + "config_autoAttachOnOpenedWithOwner_childTop": { "message": "親のタブの最初の子として" }, + "config_autoAttachOnOpenedWithOwner_childEnd": { "message": "親のタブの最後の子として" }, + "config_autoAttachOnOpenedWithOwner_sibling": { "message": "親のタブと同階層に" }, + "config_autoAttachOnOpenedWithOwner_nextSibling": { "message": "親のタブと同階層で隣に" }, + "config_autoAttachOnOpenedWithOwner_after": { "message": "開く" }, + + "config_insertNewTabFromFirefoxViewAt_caption": { "message": "Firefox Viewから(最上位のタブとして表れる)新しい子タブを開く位置" }, + "config_insertNewTabFromFirefoxViewAt_noControl": { "message": "制御しない(ブラウザーや他の拡張機能の判断に任せる)" }, + "config_insertNewTabFromFirefoxViewAt_nextToLastRelatedTab": { "message": "直近に開いた子タブの隣、もしくは親タブの近く" }, + "config_insertNewTabFromFirefoxViewAt_top": { "message": "ツリーの先頭(親タブの近く)" }, + "config_insertNewTabFromFirefoxViewAt_end": { "message": "ツリーの末尾" }, + + "config_autoGroupNewTabsFromFirefoxView_label": { "message": "Firefox Viewから開かれたタブを自動的にグループ化する(「~から開いたタブ」というタイトルのグループ化用タブが開かれます)" }, + "config_groupTabTemporaryStateForChildrenOfFirefoxView_label": { "message": "Firefox Viewから開かれたタブをまとめるグループ化用のタブの初期状態:" }, + + "config_insertNewTabFromPinnedTabAt_caption": { "message": "ピン留めされたタブから(最上位のタブとして表れる)新しい子タブを開く位置" }, + "config_insertNewTabFromPinnedTabAt_noControl": { "message": "制御しない(ブラウザーや他の拡張機能の判断に任せる)" }, + "config_insertNewTabFromPinnedTabAt_nextToLastRelatedTab": { "message": "直近に開いた子タブの隣、もしくは親タブの近く" }, + "config_insertNewTabFromPinnedTabAt_top": { "message": "ツリーの先頭(親タブの近く)" }, + "config_insertNewTabFromPinnedTabAt_end": { "message": "ツリーの末尾" }, + + "config_autoGroupNewTabsFromPinned_label": { "message": "同じピン留めされたタブから開かれたタブを自動的にグループ化する(「~から開いたタブ」というタイトルのグループ化用タブが開かれます)" }, + "config_groupTabTemporaryStateForChildrenOfPinned_label": { "message": "同じピン留めされたタブから開かれたタブをまとめるグループ化用のタブの初期状態:" }, + + + "config_newTab_caption": { "message": "既存のタブ以外から開かれた新しいタブ" }, + + "config_newTabButton_caption": { "message": "「新しいタブ」ボタンの特殊操作" }, + "config_longPressOnNewTabButton_before": { "message": "ボタンの長押しで" }, + "config_longPressOnNewTabButton_newTabAction": { "message": "関係を指定して新しいタブを開く" }, + "config_longPressOnNewTabButton_contextualIdentities": { "message": "コンテナータブを開く" }, + "config_longPressOnNewTabButton_none": { "message": "何もしない" }, + "config_longPressOnNewTabButton_after": { "message": "\u200b" }, + "config_showNewTabActionSelector_label": { "message": "ボタンをポイントした時に、関係を指定して新しいタブを開くためのボタンを表示する" }, + "config_showContextualIdentitiesSelector_label": { "message": "ボタンをポイントした時に、コンテナータブを選択するためのボタンを表示する" }, + + "config_newTabAction_caption": { "message": "新しい空のタブの基本的な取り扱い" }, + "config_autoAttachOnNewTabCommand_before": { "message": "新しい空のタブを" }, + "config_autoAttachOnNewTabCommand_noControl": { "message": "(制御せず既定の位置に)" }, + "config_autoAttachOnNewTabCommand_independent": { "message": "独立したタブとして" }, + "config_autoAttachOnNewTabCommand_childTop": { "message": "現在のタブの最初の子として" }, + "config_autoAttachOnNewTabCommand_childEnd": { "message": "現在のタブの最後の子として" }, + "config_autoAttachOnNewTabCommand_sibling": { "message": "現在のタブと同階層に" }, + "config_autoAttachOnNewTabCommand_nextSibling": { "message": "現在のタブと同階層で隣に" }, + "config_autoAttachOnNewTabCommand_after": { "message": "開く" }, + "config_guessNewOrphanTabAsOpenedByNewTabCommandTitle_before": { "message": "タイトルが" }, + "config_guessNewOrphanTabAsOpenedByNewTabCommandUrl_before": { "message": "またはURLが" }, + "config_guessNewOrphanTabAsOpenedByNewTabCommandUrl_after": { "message": "であるタブが開かれた場合、新しい空のタブが開かれたとみなす(※「|」で区切って複数の値を列挙可能)" }, + + "config_autoAttachOnNewTabButtonMiddleClick_before": { "message": "中クリックした時、新しい空のタブを" }, + "config_autoAttachOnNewTabButtonMiddleClick_noControl": { "message": "(制御せず既定の位置に)" }, + "config_autoAttachOnNewTabButtonMiddleClick_independent": { "message": "独立したタブとして" }, + "config_autoAttachOnNewTabButtonMiddleClick_childTop": { "message": "現在のタブの最初の子として" }, + "config_autoAttachOnNewTabButtonMiddleClick_childEnd": { "message": "現在のタブの最後の子として" }, + "config_autoAttachOnNewTabButtonMiddleClick_sibling": { "message": "現在のタブと同階層に" }, + "config_autoAttachOnNewTabButtonMiddleClick_nextSibling": { "message": "現在のタブと同階層で隣に" }, + "config_autoAttachOnNewTabButtonMiddleClick_nextSiblingWithInheritedContainer": { "message": "Ctrl-クリックと同様に(同階層で隣に、同じコンテナーで)" }, + "config_autoAttachOnNewTabButtonMiddleClick_after": { "message": "開く" }, + + "config_middleClickPasteURLOnNewTabButton_label": { "message": "新しいタブをクリップボード内のURLで開く(※ブラウザー組み込みの機能である\"browser.tabs.searchclipboardfor.middleclick\"=\"true\"の再現:\"dom.events.asyncClipboard.clipboardItem\"の有効化が必要)" }, + + "config_autoAttachOnNewTabButtonAccelClick_before": { "message": "Ctrl/⌘-クリックした時、新しい空のタブを" }, + "config_autoAttachOnNewTabButtonAccelClick_noControl": { "message": "(制御せず既定の位置に)" }, + "config_autoAttachOnNewTabButtonAccelClick_independent": { "message": "独立したタブとして" }, + "config_autoAttachOnNewTabButtonAccelClick_childTop": { "message": "現在のタブの最初の子として" }, + "config_autoAttachOnNewTabButtonAccelClick_childEnd": { "message": "現在のタブの最後の子として" }, + "config_autoAttachOnNewTabButtonAccelClick_sibling": { "message": "現在のタブと同階層に" }, + "config_autoAttachOnNewTabButtonAccelClick_nextSibling": { "message": "現在のタブと同階層で隣に" }, + "config_autoAttachOnNewTabButtonAccelClick_nextSiblingWithInheritedContainer": { "message": "同階層で隣に、同じコンテナーで" }, + "config_autoAttachOnNewTabButtonAccelClick_after": { "message": "開く" }, + + "config_autoAttachWithURL_caption": { "message": "空でない新しいタブ" }, + + "config_duplicateTabAction_caption": { "message": "複製されたタブ(「再読み込み」ボタンの中クリックなど)" }, + + "config_autoAttachOnDuplicated_before": { "message": "複製されたタブを" }, + "config_autoAttachOnDuplicated_noControl": { "message": "(制御せず既定の位置に)" }, + "config_autoAttachOnDuplicated_independent": { "message": "独立したタブとして" }, + "config_autoAttachOnDuplicated_childTop": { "message": "現在のタブの最初の子として" }, + "config_autoAttachOnDuplicated_childEnd": { "message": "現在のタブの最後の子として" }, + "config_autoAttachOnDuplicated_sibling": { "message": "現在のタブと同階層に" }, + "config_autoAttachOnDuplicated_nextSibling": { "message": "現在のタブと同階層で隣に" }, + "config_autoAttachOnDuplicated_after": { "message": "開く" }, + + "config_fromExternal_caption": { "message": "他のアプリケーションから開かれたタブ" }, + "config_autoAttachOnOpenedFromExternal_before": { "message": "\u200b" }, + "config_autoAttachOnOpenedFromExternal_noControl": { "message": "(制御せず既定の位置に)" }, + "config_autoAttachOnOpenedFromExternal_independent": { "message": "独立したタブとして" }, + "config_autoAttachOnOpenedFromExternal_childTop": { "message": "現在のタブの最初の子として" }, + "config_autoAttachOnOpenedFromExternal_childEnd": { "message": "現在のタブの最後の子として" }, + "config_autoAttachOnOpenedFromExternal_sibling": { "message": "現在のタブと同階層に" }, + "config_autoAttachOnOpenedFromExternal_nextSibling": { "message": "現在のタブと同階層で隣に" }, + "config_autoAttachOnOpenedFromExternal_after": { "message": "開く" }, + "config_inheritContextualIdentityToTabsFromExternalMode_label": { "message": "コンテナー:" }, + "config_inheritContextualIdentityToTabsFromExternalMode_default": { "message": "(制御しない)" }, + "config_inheritContextualIdentityToTabsFromExternalMode_parent": { "message": "ツリー上での親から継承" }, + "config_inheritContextualIdentityToTabsFromExternalMode_lastActive": { "message": "現在のタブから継承" }, + + "config_sameSiteOrphan_caption": { "message": "ロケーションバー、ブックマーク、履歴、およびその他のきっかけから開かれた、現在のタブと同じWebサイトのタブ" }, + "config_autoAttachSameSiteOrphan_before": { "message": "\u200b" }, + "config_autoAttachSameSiteOrphan_noControl": { "message": "(制御せず既定の位置に)" }, + "config_autoAttachSameSiteOrphan_independent": { "message": "独立したタブとして" }, + "config_autoAttachSameSiteOrphan_childTop": { "message": "現在のタブの最初の子として" }, + "config_autoAttachSameSiteOrphan_childEnd": { "message": "現在のタブの最後の子として" }, + "config_autoAttachSameSiteOrphan_sibling": { "message": "現在のタブと同階層に" }, + "config_autoAttachSameSiteOrphan_nextSibling": { "message": "現在のタブと同階層で隣に" }, + "config_autoAttachSameSiteOrphan_after": { "message": "開く" }, + "config_inheritContextualIdentityToSameSiteOrphanMode_label": { "message": "コンテナー:" }, + "config_inheritContextualIdentityToSameSiteOrphanMode_default": { "message": "(制御しない)" }, + "config_inheritContextualIdentityToSameSiteOrphanMode_parent": { "message": "ツリー上での親から継承" }, + "config_inheritContextualIdentityToSameSiteOrphanMode_lastActive": { "message": "現在のタブから継承" }, + + "config_anyOtherTrigger_caption": { "message": "それ以外の契機で開かれたタブ" }, + "config_autoAttachOnAnyOtherTrigger_before": { "message": "\u200b" }, + "config_autoAttachOnAnyOtherTrigger_noControl": { "message": "(制御せず既定の位置に)" }, + "config_autoAttachOnAnyOtherTrigger_independent": { "message": "独立したタブとして" }, + "config_autoAttachOnAnyOtherTrigger_childTop": { "message": "現在のタブの最初の子として" }, + "config_autoAttachOnAnyOtherTrigger_childEnd": { "message": "現在のタブの最後の子として" }, + "config_autoAttachOnAnyOtherTrigger_sibling": { "message": "現在のタブと同階層に" }, + "config_autoAttachOnAnyOtherTrigger_nextSibling": { "message": "現在のタブと同階層で隣に" }, + "config_autoAttachOnAnyOtherTrigger_after": { "message": "開く" }, + "config_autoAttachOnAnyOtherTrigger_caution": { "message": "※警告:このオプションは他の拡張機能の動作を妨げる危険性があります" }, + "config_inheritContextualIdentityToTabsFromAnyOtherTriggerMode_label": { "message": "コンテナー:" }, + "config_inheritContextualIdentityToTabsFromAnyOtherTriggerMode_default": { "message": "(制御しない)" }, + "config_inheritContextualIdentityToTabsFromAnyOtherTriggerMode_parent": { "message": "ツリー上での親から継承" }, + "config_inheritContextualIdentityToTabsFromAnyOtherTriggerMode_lastActive": { "message": "現在のタブから継承" }, + + "config_inheritContextualIdentityToUnopenableURLTabs_label": { "message": "拡張機能の権限で開けないURLのタブを、コンテナーを継承するために安全なURLで強制的に開きなおす" }, + + "config_groupTab_caption": { "message": "タブを自動的にグループ化する" }, + + "config_tabBunchesDetectionTimeout_before": { "message": "1つのブックマークフォルダーから" }, + "config_tabBunchesDetectionTimeout_after": { "message": "ミリ秒以内に連続して開かれたタブを検出して、" }, + "config_autoGroupNewTabsFromBookmarks_label": { "message": "グループ化する(「~など」というタイトルのグループ化用タブが開かれます)" }, + "config_restoreTreeForTabsFromBookmarks_label": { "message": "ツリー構造を復元する" }, + "config_requireBookmarksPermission_tooltiptext": { "message": "この機能はブックマークへのアクセス権を必要とします。ここをクリックして必要な権限を与えてください。" }, + "config_requestPermissions_bookmarks_autoGroupNewTabs_before": { "message": "(" }, + "config_requestPermissions_bookmarks_autoGroupNewTabs_after": { "message": "タブとブックマークの対応関係の検出を許可する)" }, + "config_tabsFromSameFolderMinThresholdPercentage_before": { "message": "開かれたタブのURLのうち" }, + "config_tabsFromSameFolderMinThresholdPercentage_after": { "message": "%以上が共通のフォルダー内にブックマークされていれば、開かれたタブすべてがそのブックマークフォルダーから開かれたと判断する" }, + "config_autoGroupNewTabsFromOthers_label": { "message": "ブックマーク以外から連続して開かれたタブも自動的にグループ化する" }, + "config_tabBunchesDetectionDelayOnNewWindow_before": { "message": "ただし、新しいウィンドウが開かれてから" }, + "config_tabBunchesDetectionDelayOnNewWindow_after": { "message": "ミリ秒以内に開かれたタブは「ホームページ」の一部と見なしてグループ化しない" }, + "config_warnOnAutoGroupNewTabs_label": { "message": "自動的にグループ化する前に確認する" }, + "config_warnOnAutoGroupNewTabsWithListing_label": { "message": "グループ化しようとしているタブの一覧を確認のダイアログに表示する" }, + + "config_renderTreeInGroupTabs_label": { "message": "グループ化用のタブにツリーを表示する" }, + + "config_groupTabTemporaryState_caption": { "message": "場合ごとのグループ化用のタブの初期状態" }, + "config_groupTabTemporaryState_option_default": { "message": "何もチェックしない" }, + "config_groupTabTemporaryState_option_checked_before": { "message": "「" }, + "config_groupTabTemporaryState_option_checked_after": { "message": "」をチェック済みにする" }, + "config_groupTabTemporaryStateForNewTabsFromBookmarks_label": { "message": "ブックマークから開かれたタブをグループ化した場合:" }, + "config_groupTabTemporaryStateForNewTabsFromOthers_label": { "message": "ブックマーク以外から連続して開かれたタブをグループ化した場合:" }, + "config_groupTabTemporaryStateForOrphanedTabs_label": { "message": "閉じられた親タブを置き換える形で開かれた場合:" }, + "config_groupTabTemporaryStateForAPI_label": { "message": "他の拡張機能からAPI経由でタブをグループ化した場合:" }, + + "config_inheritContextualIdentityToChildTabMode_label": { "message": "コンテナー:" }, + "config_inheritContextualIdentityToChildTabMode_default": { "message": "(制御しない)" }, + "config_inheritContextualIdentityToChildTabMode_parent": { "message": "ツリー上での親から継承" }, + "config_inheritContextualIdentityToChildTabMode_lastActive": { "message": "現在のタブから継承" }, + + + "config_treeBehavior_caption": { "message": "ツリーの挙動" }, + + "config_successorTabControlLevel_caption": { "message": "現在のタブが最後の子タブだった時に、現在のタブを閉じた後は" }, + "config_successorTabControlLevel_inTree": { "message": "ツリー内の1つ前のタブにフォーカスを移す" }, + "config_successorTabControlLevel_simulateDefault": { "message": "次のタブにフォーカスを移す(ブラウザー既定の動作)" }, + "config_successorTabControlLevel_never": { "message": "フォーカスを制御しない(ブラウザーまたは他の拡張機能による制御を尊重)" }, + "config_successorTabControlLevel_legacyDescription": { "message": "※ブラウザー組み込みの機能である\"browser.tabs.selectOwnerOnClose\"=\"true\"の挙動の方が、この設定よりも優先的に反映されます。" }, + "config_simulateSelectOwnerOnClose_label": { "message": "現在のタブが閉じられた時、可能であればそのタブを開いた元のタブにフォーカスを戻す(※ブラウザー組み込みの機能である\"browser.tabs.selectOwnerOnClose\"=\"true\"の挙動の再現。上記の設定よりも優先的に反映されます。)" }, + + "config_fixupTreeOnTabVisibilityChanged_caption": { "message": "タブの表示・非表示が他の拡張機能によって切り替えられた場合" }, + "config_fixupTreeOnTabVisibilityChanged_fix": { "message": "表示中のタブに基づいてツリー構造を自動的に修正する(※タブのグループを切り替える拡張機能との併用時に推奨)" }, + "config_fixupTreeOnTabVisibilityChanged_keep": { "message": "非表示のタブも含めてツリー構造を維持する(※一時的にタブを非表示にする拡張機能との併用時に推奨)" }, + + "config_autoCollapseExpandSubtreeOnAttach_label": { "message": "新しいツリーが作られた時は、自動的に他のツリーを折りたたむ" }, + "config_autoCollapseExpandSubtreeOnSelect_label": { "message": "タブを切り替えた時は、フォーカスされたタブのツリーを自動的に展開して、他のツリーを折りたたむ" }, + "config_autoCollapseExpandSubtreeOnSelectExceptActiveTabRemove_label": { "message": "現在のタブを閉じてタブのフォーカスが切り替わった場合を除く" }, + "config_unfocusableCollapsedTab_label": { "message": "折りたたまれたツリー配下のタブがフォーカスされた時に、そのタブが含まれるツリーを自動的に展開する" }, + "config_autoDiscardTabForUnexpectedFocus_label": { "message": "待機状態のタブが意図せずフォーカスされた後にすぐ別のタブへフォーカスし直された場合、タブを待機状態に戻す" }, + "config_avoidDiscardedTabToBeActivatedIfPossible_label": { "message": "現在のタブを閉じたりツリーを折りたたんだりした時に、なるべく待機状態のタブが選択されないようにする" }, + + "config_treeDoubleClickBehavior_caption": { "message": "タブのダブルクリック" }, + "config_treeDoubleClickBehavior_toggleCollapsed": { "message": "ツリーを開閉する" }, + "config_treeDoubleClickBehavior_toggleSticky": { "message": "タブバー端に貼り付ける/貼り付けを解除" }, + "config_treeDoubleClickBehavior_close": { "message": "タブを閉じる" }, + "config_treeDoubleClickBehavior_close_note": { "message": "(※ブラウザー組み込みの機能である\"browser.tabs.closeTabByDblclick\"=\"true\"の再現)" }, + "config_treeDoubleClickBehavior_none": { "message": "何もしない" }, + + "config_parentTabOperationBehaviorMode_caption": { "message": "親タブを閉じたり移動したりした時の挙動" }, + "config_parentTabOperationBehaviorMode_noteForPermanentlyConsistentBehaviors": { "message": "ここでの設定に関わらず、サイドバー内での以下の動作は常に固定です;\n・ 折りたたまれたツリーの親タブを閉じる: 子孫タブも一緒に閉じる\n・ 折りたたまれたツリーの親タブを移動: 子孫タブも一緒に移動\n・ 展開状態のツリーの親タブを移動: 「ドラッグ&ドロップ」での設定に従う" }, + + "config_parentTabOperationBehaviorMode_parallel": { "message": "タブを個別に操作するUIとしてブラウザー自身のタブバーを使う場合の推奨動作" }, + "config_closeParentBehavior_insideSidebar": { "message": "展開状態のツリーの親タブをサイドバー内で閉じた時は、" }, + "config_closeParentBehavior_outsideSidebar": { "message": "ツリーの開閉状態に関わらず、ブラウザー自身のタブバー、キーボードショートカット、他の拡張機能でツリーの親タブを閉じた時は、" }, + "config_moveParentBehavior_outsideSidebar": { "message": "ツリーの開閉状態に関わらず、ブラウザー自身のタブバー、キーボードショートカット、他の拡張機能でツリーの親タブを移動した時は、" }, + + "config_parentTabOperationBehaviorMode_consistent": { "message": "タブの振る舞いを全面的にTSTで制御する場合の推奨動作" }, + "config_parentTabOperationBehaviorMode_consistent_caption": { "message": "展開状態のツリーの親タブを閉じた/移動した時は、" }, + "config_parentTabOperationBehaviorMode_consistent_notes": { "message": "ブラウザー自身のタブバー、キーボードショートカット、他の拡張機能での操作に対しても、常にこの通り動作します。" }, + + "config_parentTabOperationBehaviorMode_custom": { "message": "カスタム" }, + "config_closeParentBehavior_insideSidebar_expanded_caption": { "message": "閉じる:展開状態のツリーの親タブを、サイドバー内で閉じた場合は、" }, + "config_closeParentBehavior_outsideSidebar_collapsed_caption": { "message": "閉じる:折りたたまれたツリーの親タブを、ブラウザー自身のタブバー、キーボードショートカット、他の拡張機能で閉じた場合は、" }, + "config_closeParentBehavior_noSidebar_collapsed_caption": { "message": "サイドバーが非表示の時は、" }, + "config_closeParentBehavior_outsideSidebar_expanded_caption": { "message": "閉じる:展開状態のツリーの親タブを、ブラウザー自身のタブバー、キーボードショートカット、他の拡張機能で閉じた場合は、" }, + "config_closeParentBehavior_noSidebar_expanded_caption": { "message": "サイドバーが非表示の時は、" }, + "config_moveParentBehavior_outsideSidebar_collapsed_caption": { "message": "移動:折りたたまれたツリーの親タブを、ブラウザー自身のタブバー、キーボードショートカット、他の拡張機能で移動した場合は、" }, + "config_moveParentBehavior_noSidebar_collapsed_caption": { "message": "サイドバーが非表示の時は、" }, + "config_moveParentBehavior_outsideSidebar_expanded_caption": { "message": "移動:展開状態のツリーの親タブを、ブラウザー自身のタブバー、キーボードショートカット、他の拡張機能で移動した場合は、" }, + "config_moveParentBehavior_noSidebar_expanded_caption": { "message": "サイドバーが非表示の時は、" }, + + "config_parentTabOperationBehavior_entireTree": { "message": "ツリー全体を閉じる/移動する" }, + "config_closeParentBehavior_entireTree": { "message": "ツリー全体を閉じる" }, + "config_moveParentBehavior_entireTree": { "message": "ツリー全体を移動する" }, + "config_parentTabOperationBehavior_replaceWithGroupTab": { "message": "閉じた/移動した親の代わりにグループ化用のタブを開く" }, + "config_closeParentBehavior_replaceWithGroupTab": { "message": "閉じた親の代わりにグループ化用のタブを開く" }, + "config_moveParentBehavior_replaceWithGroupTab": { "message": "移動した親の代わりにグループ化用のタブを開く" }, + "config_parentTabOperationBehavior_promoteFirst": { "message": "常に最初の子タブを新しい親に昇格させる" }, + "config_closeParentBehavior_promoteFirst": { "message": "常に最初の子タブを新しい親に昇格させる" }, + "config_moveParentBehavior_promoteFirst": { "message": "常に最初の子タブを新しい親に昇格させる" }, + "config_parentTabOperationBehavior_promoteAll": { "message": "常にすべての子タブを昇格させる" }, + "config_closeParentBehavior_promoteAll": { "message": "常にすべての子タブを昇格させる" }, + "config_moveParentBehavior_promoteAll": { "message": "常にすべての子タブを昇格させる" }, + "config_parentTabOperationBehavior_promoteIntelligently": { "message": "最上位階層では最初の子を昇格させ、それ以外はすべての子タブを昇格" }, + "config_closeParentBehavior_promoteIntelligently": { "message": "最上位階層では最初の子を昇格させ、それ以外はすべての子タブを昇格" }, + "config_moveParentBehavior_promoteIntelligently": { "message": "最上位階層では最初の子を昇格させ、それ以外はすべての子タブを昇格" }, + "config_parentTabOperationBehavior_detach": { "message": "子孫のタブをツリーから解放する" }, + "config_closeParentBehavior_detach": { "message": "子孫のタブをツリーから解放する" }, + "config_moveParentBehavior_detach": { "message": "子孫のタブをツリーから解放する" }, + + "config_warnOnCloseTabs_label": { "message": "同時に複数のタブを閉じるときは確認する" }, + "config_warnOnCloseTabsByClosebox_label": { "message": "クローズボックスの通常のクリック操作で同時に複数のタブを閉じるときは確認する" }, + "config_warnOnCloseTabsWithListing_label": { "message": "閉じようとしているタブの一覧を確認のダイアログに表示する" }, + + "config_insertNewChildAt_caption": { "message": "子タブの挿入位置が未制御の場合の既定の挿入位置" }, + "config_insertNewChildAt_noControl": { "message": "制御しない(ブラウザーや他の拡張機能の判断に任せる)" }, + "config_insertNewChildAt_nextToLastRelatedTab": { "message": "直近に開いた子タブの隣、もしくは親タブの隣" }, + "config_insertNewChildAt_top": { "message": "ツリーの先頭(親タブの隣)" }, + "config_insertNewChildAt_end": { "message": "ツリーの末尾" }, + + + "config_drag_caption": { "message": "ドラッグ&ドロップ" }, + + "config_tabDragBehavior_caption": { "message": "サイドバー内で親タブをドラッグ開始した時の動作" }, + "config_tabDragBehavior_description": { "message": "ブラウザーの制限のため、タブバー外へのドロップ後にタブをどう取り扱うかは、ドラッグ操作開始時に決定する必要があります。" }, + "config_tabDragBehavior_label": { "message": "通常のドラッグ" }, + "config_tabDragBehaviorShift_label": { "message": "Shift-ドラッグ" }, + "config_tabDragBehavior_label_behaviorInsideSidebar": { "message": "サイドバー内へのドロップ" }, + "config_tabDragBehavior_label_behaviorOutsideSidebar": { "message": "サイドバー外へのドロップ" }, + "config_tabDragBehavior_moveEntireTreeAlways": { "message": "常にツリー全体を移動する" }, + "config_tabDragBehavior_detachEntireTreeAlways": { "message": "常にツリー全体を別ウィンドウに切り離す(ブックマークの作成はできません)" }, + "config_tabDragBehavior_bookmarkEntireTreeAlways": { "message": "常にツリー全体のタブからリンクかブックマークを作成する(別ウィンドウへの切り離しはできません)" }, + "config_tabDragBehavior_moveSoloTabIfExpanded": { "message": "展開状態のツリーではタブ単体を移動し、折りたたまれているツリーでは全体を移動する" }, + "config_tabDragBehavior_detachSoloTabIfExpanded": { "message": "展開状態のツリーではタブ単体を、折りたたまれているツリーでは全体を別ウィンドウに切り離す(ブックマークの作成はできません)" }, + "config_tabDragBehavior_bookmarkSoloTabIfExpanded": { "message": "展開状態のツリーではタブ単体から、折りたたまれているツリーでは全体からリンクかブックマークを作成する(別ウィンドウへの切り離しはできません)" }, + "config_tabDragBehavior_doNothing": { "message": "何もしない" }, + "config_tabDragBehavior_noteForDragstartOutsideSidebar": { "message": "ブラウザー自身のタブバー上で親タブをドラッグ開始した時の動作は、「ツリーの挙動」→「親タブを閉じたり移動したりした時の挙動」での指定に準じます" }, + "config_showTabDragBehaviorNotification_label": { "message": "タブのドラッグ中、サイドバー外にドロップした時の操作説明を表示する" }, + + "config_dropLinksOnTabBehavior_caption": { "message": "タブにリンクやURL文字列をドロップしたとき" }, + "config_dropLinksOnTabBehavior_ask": { "message": "どうするか常に訊ねる" }, + "config_dropLinksOnTabBehavior_load": { "message": "そのタブに読み込む" }, + "config_dropLinksOnTabBehavior_newtab": { "message": "新しい子タブで開く" }, + "config_simulateTabsLoadInBackgroundInverted_label": { "message": "ドロップされたリンクを新しいタブで開いたとき、すぐにそのタブに切り替える(※ブラウザー組み込みの設定項目「リンク、画像、メディアを新しいタブで開いたとき、すぐにそのタブに切り替える」(\"browser.tabs.loadInBackground\")の再現)" }, + "config_tabsLoadInBackgroundDiscarded_label": { "message": "読み込みを保留した状態でタブを開く" }, + + "config_insertDroppedTabsAt_caption": { "message": "親タブにドロップされた子タブの挿入位置" }, + "config_insertDroppedTabsAt_inherit": { "message": "親タブから新しい子タブを開いたときと同様に扱う" }, + "config_insertDroppedTabsAt_first": { "message": "ツリーの先頭(親タブの隣)" }, + "config_insertDroppedTabsAt_end": { "message": "ツリーの末尾" }, + + "config_autoCreateFolderForBookmarksFromTree_label": { "message": "ツリー構造を持ったタブを複数選択してのドラッグ&ドロップなどの操作で複数のブックマークが作成されたと推測できる場合に、作成されたブックマークを自動的に1つのフォルダーにまとめる" }, + "config_autoExpandOnLongHoverDelay_before": { "message": "ドラッグ操作中、折りたたまれたツリーの上で" }, + "config_autoExpandOnLongHoverDelay_after": { "message": "ミリ秒以上経過したらツリーを展開する" }, + "config_autoExpandOnLongHoverRestoreIniitalState_label": { "message": "ドラッグ操作の終了後、ツリーを折りたたんだ状態に自動的に戻す" }, + "config_autoExpandIntelligently_label": { "message": "折りたたまれたツリーを自動展開したときに、他のツリーを自動的に折りたたむ" }, + "config_ignoreTabDropNearSidebarArea_label": { "message": "サイドバーの近くにタブをドロップした場合はウィンドウから切り離さない(\"privacy.resistFingerprinting\"=\"true\" に設定しているときはチェックを外すことをおすすめします)" }, + + + "config_more_caption": { "message": "その他..." }, + + "config_shortcuts_caption": { "message": "キーボードショートカット" }, + + "config_requestPermissions_allUrls_ctrlTabTracking": { "message": "キーボードショートカットでのタブ切り替え中は、折りたたまれた子孫タブにはフォーカスせず、折りたたまれたツリーも展開しない(※Webページ内でのスクリプトの実行を許可する必要があります)" }, + "config_autoExpandOnTabSwitchingShortcutsDelay_before": { "message": "折りたたまれたツリーにフォーカスがある状態でTabキーを" }, + "config_autoExpandOnTabSwitchingShortcutsDelay_after": { "message": "ミリ秒以上押し続けたらツリーを展開する" }, + "config_accelKey_label": { "message": "キーボードショートカットのアクセラレータキー(※「ui.key.accelKey」の設定と一致させる必要があります):" }, + "config_accelKey_auto": { "message": "既定(macOS=⌘, それ以外=Ctrl)" }, + "config_accelKey_alt": { "message": "Alt" }, + "config_accelKey_control": { "message": "Ctrl/Control" }, + "config_accelKey_meta": { "message": "Meta/⌘" }, + + "config_shortcuts_resetAll": { "message": "すべて初期値に戻す" }, + + + "config_advanced_caption": { "message": "詳細設定" }, + + "config_bookmarkTreeFolderName_before": { "message": "ツリーをブックマークする時のフォルダー名:" }, + "config_bookmarkTreeFolderName_after": { "message": "\u200b" }, + "config_bookmarkTreeFolderName_description": { "message": "以下のプレースホルダを使用できます: %TITLE%(1つ目のタブのタイトル), %URL%(1つ目のタブのURL), %YEAR%(4桁の年), %SHORT_YEAR%(2桁の年), %MONTH%(2桁の月), %DATE%(2桁の日), %HOURS%(2桁の時間), %MINUTES%(2桁の分), %SECONDS%(2桁の秒), %MILLISECONDS%(3桁のミリ秒), %GROUP%(最初のタブがグループタブだった場合はそのタイトル、それ以外の場合は空), %ANY(値1, 値2, ...)%(与えられたリストの中で最初に有効な値)" }, + + "config_defaultBookmarkParentId_label": { "message": "ブックマークの作成先フォルダーの既定値" }, + + "config_tabGroupsEnabled_label": { "message": "ネイティブのタブグループの管理を有効化する(※ブラウザーの設定である\"browser.tabs.groups.enabled\"=\"true\"に対応)" }, + + "config_undoMultipleTabsClose_label": { "message": "最後にまとめて閉じたタブ群のうちの1つが「閉じたタブを元に戻す」で復元されたとき、タブ群全体を復元する" }, + + "config_scrollLines_label_before": { "message": "キーボードショートカットの「タブの一覧を数行上/下にスクロール」で" }, + "config_scrollLines_label_after": { "message": "行分スクロールする" }, + + "config_userStyleRules_label": { "message": "ユーザースタイルシート(サイドバーなど、ツリー型タブが提供するページ用の追加のスタイル指定)" }, + "config_userStyleRules_description_before": { "message": "\u200b" }, + "config_userStyleRules_description_link_label": { "message": "目的別の指定例" }, + "config_userStyleRules_description_after": { "message": "も参照して下さい。" }, + "config_userStyleRules_description_link_uri": { "message": "https://github.com/piroor/treestyletab/wiki/Code-snippets-for-custom-style-rules#for-version-2x" }, + "config_userStyleRules_themeRules_description": { "message": "現在のブラウザのテーマに基づいて、「var()」で以下のカスタムプロパティも使用できます:" }, + "config_userStyleRules_themeRules_description_alphaVariations": { "message": "「--theme-colors-tab_background_text-30」のようにカスタムプロパティ名の後に不透明度を指定すると、10%刻みで不透明度を変えられます(この例では不透明度30%)" }, + "config_userStyleRules_import": { "message": "ファイルから読み込み" }, + "config_userStyleRules_export": { "message": "ファイルに保存" }, + "config_userStyleRules_overwrite_title": { "message": "現在の内容を置き換えますか?" }, + "config_userStyleRules_overwrite_message": { "message": "読み込んだ内容で現在のスタイル指定をすべて置き換えますか?" }, + "config_userStyleRules_overwrite_overwrite": { "message": "すべて置き換える" }, + "config_userStyleRules_overwrite_append": { "message": "最後に追加する" }, + "config_tooLargeUserStyleRulesCaution": { "message": "スタイル指定が長すぎます! もっと短くしてください" }, + "config_userStyleRulesTheme_label": { "message": "テーマ:" }, + "config_userStyleRulesTheme_auto": { "message": "自動" }, + "config_userStyleRulesTheme_separator": { "message": "-----------------" }, + "config_userStyleRulesTheme_default": { "message": "既定" }, + + + "config_addons_caption": { "message": "他の拡張機能による機能追加" }, + + "config_addons_description_before": { "message": "\u200b" }, + "config_addons_description_link_label": { "message": "TST自体を拡張する他の拡張機能" }, + "config_addons_description_after": { "message": "を組み合わせると、TSTのサイドバーにさらに機能を追加できます。" }, + "helper_addons_list_link_uri": { "message": "https://github.com/piroor/treestyletab/wiki/TST%E3%81%AE%E6%A9%9F%E8%83%BD%E3%82%92%E6%8B%A1%E5%BC%B5%E3%81%99%E3%82%8B%E3%82%A2%E3%83%89%E3%82%AA%E3%83%B3" }, + + "config_theme_description_before": { "message": "組み込みのテーマは現在「Plain」「Sidebar」「ハイコントラスト」のみ利用可能です。他のテーマを使用したい場合、" }, + "config_theme_description_link_label": { "message": "コードスニペット" }, + "config_theme_description_after": { "message": "にある例を参照して下さい。" }, + "helper_theme_list_link_uri": { "message": "https://github.com/piroor/treestyletab/wiki/Code-snippets-for-custom-style-rules#restore-old-built-in-themes" }, + + "config_externaladdonpermissions_label": { "message": "他の拡張機能からのAPI呼び出しに与える許可" }, + "config_externaladdonpermissions_description": { "message": "ここでチェックを入れた拡張機能は、ブラウザー自体がその拡張機能にタブの詳細情報やプライベートウィンドウのタブへのアクセスを禁止していても、Tabs Sidebarの権限でそれらにアクセスできるようになります。信用できない拡張機能に対しては絶対に権限を与えないで下さい。" }, + "config_externalAddonPermissions_header_name": { "message": "拡張機能名" }, + "config_externalAddonPermissions_header_permissions": { "message": "許可する特別な操作" }, + "config_externalAddonPermissions_header_incognito": { "message": "プライベートウィンドウからのメッセージを通知する" }, + + "addon_containerBookmarks_label": { "message": "Container Bookmarks" }, + "addon_containerBookmarks_uri": { "message": "https://addons.mozilla.org/firefox/addon/container-bookmarks/" }, + "config_containerRedirectKey_label": { "message": "リダイレクトキー(※Container Bookmarksの設定画面の値と揃えて下さい):" }, + + + "config_debug_caption": { "message": "開発用" }, + + "config_link_startupPage_label": { "message": "初回起動時用のページ" }, + "config_link_groupPage_label": { "message": "グループ化用タブのページ" }, + "config_link_tabbarPage_label": { "message": "タブバーのページ" }, + + "config_runTests_label": { "message": "自動テストを実行" }, + "config_runTestsParameters_label": { "message": " / 実行するテストの指定(テスト名または正規表現の配列。例:「testInheritMutedState,/^testHidden/,...」):" }, + "config_runBenchmark_label": { "message": "ベンチマークを実行" }, + "config_enableLinuxBehaviors_label": { "message": "Linux専用の挙動を有効化する" }, + "config_enableMacOSBehaviors_label": { "message": "macOS専用の挙動を有効化する" }, + "config_enableWindowsBehaviors_label": { "message": "Windows専用の挙動を有効化する" }, + "config_debug_label": { "message": "デバッグモード" }, + "config_log_caption": { "message": "詳細なログ" }, + "config_logTimestamp_label": { "message": "日時をログに付与する" }, + "config_logFor_common": { "message": "共通モジュールからのログ" }, + "config_logFor_background": { "message": "バックグラウンドモジュールからのログ" }, + "config_logFor_sidebar": { "message": "サイドバーモジュールからのログ" }, + "config_loggingQueries_label": { "message": "タブの検索処理のログを収集" }, + "config_loggingConnectionMessages_label": { "message": "内部通信のログを収集" }, + "config_showLogsButton_label": { "message": "ログを表示" }, + "config_simulateSVGContextFill_label": { "message": "Bug 1388193およびBug 1421329を回避して擬似的にSVGアイコンを表示する(※CPU負荷が高まる可能性があります。このオプションを無効にする場合、これらのBugが修正されるまでは\"about:config\"で\"svg.context-properties.content.enabled\"を有効化する必要があります。)" }, + "config_staticARIALabel_label": { "message": "タブ要素のARIAラベルを固定する" }, + "config_staticARIALabel_description": { "message": "※何らかのタブ操作のあとで音声認識システムがタブ要素を見失ってしまう場合、このチェックをONにしてください。" }, + "config_useCachedTree_label": { "message": "キャッシュを使ってツリーの初期化を高速化する" }, + "config_useCachedTree_description": { "message": "※キャッシュの不整合で動作が不安定になっている場合、このチェックをOFFにして再度ONにすることでキャッシュが刷新され状況が改善するかもしれません。" }, + "config_persistCachedTree_label": { "message": "キャッシュを永続化する" }, + "config_persistCachedTree_description": { "message": "※ブラウザー起動時や拡張機能更新時などの初期化処理を短縮できますが、ブラウザーのセッションファイルが肥大化しディスクI/Oが増加します" }, + "config_acceleratedTabCreation_label": { "message": "新しいタブが開かれた時の処理を高速化する(※動作が不安定になります)" }, + "config_maximumAcceptableDelayForTabDuplication_before": { "message": "\u200b" }, + "config_maximumAcceptableDelayForTabDuplication_after": { "message": "ミリ秒以内にタブの複製を完了できなかった場合は処理を中断する" }, + "config_delayForDuplicatedTabDetection_label_before": { "message": "複製されたタブの検出の待ち時間:" }, + "config_delayForDuplicatedTabDetection_label_after": { "message": "ミリ秒(複製されたタブの検出に失敗する場合に数字を増やして下さい)" }, + "config_delayForDuplicatedTabDetection_autoDetect": { "message": "自動設定" }, + "config_delayForDuplicatedTabDetection_test": { "message": "検出率をテスト" }, + "config_delayForDuplicatedTabDetection_test_resultMessage": { "message": "識別の成功率は $PERCENTAGE$% でした", + "placeholders": { + "percentage": { "content": "$1", "example": "50" } + }}, + "config_labelOverflowStyle_caption": { "message": "タブのラベルが長すぎる場合:" }, + "config_labelOverflowStyle_fade": { "message": "フェードアウトする(視認性を重視)" }, + "config_labelOverflowStyle_crop": { "message": "「..」で切り取る(動作速度を重視)" }, + + "config_requestPermissions_bookmarks": { "message": "ブックマークの読み取りと作成を許可する" }, + "config_requestPermissions_bookmarks_context": { "message": "ブックマーク用のコンテキストメニューを開くことを許可する" }, + "config_requestPermissions_tabHide": { "message": "個々のタブの表示・非表示の制御を許可する(※この操作が許可されていることを確かめるため、テスト実行前には必ずチェックボックスを一旦OFFにしてから再度ONにして下さい)" }, + + "config_requestPermissions_fallbackToToolbarButton_title": { "message": "ツールバーの「ツリー型タブ」ボタンをクリックして下さい" }, + "config_requestPermissions_fallbackToToolbarButton_message": { "message": "ブラウザーのバグのため、権限をこの画面から要求することができません。ツールバー上の「ツリー型タブ」ボタンをクリックして権限を承認して下さい。" }, + + "config_all_caption": { "message": "すべての設定" }, + + "config_terms_delimiter": { "message": "\u200b" }, + + + "tabContextMenu_newTab_label": { "message": "新しいタブ(&W)" }, + "tabContextMenu_newTabNext_label": { "message": "新しいタブを下隣に開く(&W)" }, + "tabContextMenu_newGroup_label": { "message": "このタブを新しいグループに追加(&G)" }, + "tabContextMenu_newGroup_label_multiselected": { "message": "選択したタブを新しいグループに追加(&G)" }, + "tabContextMenu_addToGroup_label": { "message": "このタブをグループに追加(&G)" }, + "tabContextMenu_addToGroup_label_multiselected": { "message": "選択したタブをグループに追加(&G)" }, + "tabContextMenu_addToGroup_newGroup_label": { "message": "新しいグループ(&G)" }, + "tabContextMenu_addToGroup_unnamed_label": { "message": "無名のグループ" }, + "tabContextMenu_removeFromGroup_label": { "message": "このタブをグループから除外(&R)" }, + "tabContextMenu_removeFromGroup_label_multiselected": { "message": "選択したタブを各グループから除外(&R)" }, + + "tabContextMenu_reload_label": { "message": "タブを再読み込み(&R)" }, + "tabContextMenu_unblockAutoplay_label": { "message": "タブのメディアを再生(&L)" }, + "tabContextMenu_mute_label": { "message": "タブをミュート(&M)" }, + "tabContextMenu_unmute_label": { "message": "タブのミュートを解除(&M)" }, + + "tabContextMenu_pin_label": { "message": "タブをピン留め(&P)" }, + "tabContextMenu_unpin_label": { "message": "タブのピン留めを外す(&P)" }, + "tabContextMenu_unload_label": { "message": "タブを解放(&U)" }, + "tabContextMenu_duplicate_label": { "message": "タブを複製(&D)" }, + "tabContextMenu_selectAllTabs_label": { "message": "すべてのタブを選択(&S)" }, + "tabContextMenu_bookmark_label": { "message": "タブをブックマーク...(&B)" }, + "tabContextMenu_reopenInContainer_label": { "message": "新しいコンテナータブで開く(&E)" }, + "tabContextMenu_reopenInContainer_noContainer_label": { "message": "コンテナーなし(&N)" }, + "tabContextMenu_moveTab_label": { "message": "タブを移動(&V)" }, + "tabContextMenu_moveTabToStart_label": { "message": "先頭へ移動(&S)" }, + "tabContextMenu_moveTabToEnd_label": { "message": "末尾へ移動(&E)" }, + "tabContextMenu_tearOff_label": { "message": "新しいウィンドウへ移動(&W)" }, + "tabContextMenu_sendTabsToDevice_label": { "message": "%S 個のタブを端末へ送信(&N)" }, + "tabContextMenu_sendTabsToAllDevices_label": { "message": "すべての端末に送信" }, + "tabContextMenu_manageSyncDevices_label": { "message": "端末を管理..." }, + "tabContextMenu_shareTabURL_label": { "message": "共有(&H)" }, + "tabContextMenu_shareTabURL_more_label": { "message": "その他..." }, + + "tabContextMenu_closeDuplicatedTabs_label": { "message": "重複タブを閉じる(&U)" }, + "tabContextMenu_closeMultipleTabs_label": { "message": "複数のタブを閉じる(&M)" }, + "tabContextMenu_closeTabsToTop_label": { "message": "上のタブをすべて閉じる(&T)" }, + "tabContextMenu_closeTabsToBottom_label": { "message": "下のタブをすべて閉じる(&B)" }, + "tabContextMenu_closeOther_label": { "message": "他のタブをすべて閉じる(&O)" }, + + "tabContextMenu_undoClose_label": { "message": "閉じたタブを開きなおす(&U)" }, + "tabContextMenu_undoClose_label_multiple": { "message": "閉じたタブを開きなおす(&U)" }, + "tabContextMenu_close_label": { "message": "タブを閉じる(&C)" }, + + "tabContextMenu_reload_label_multiselected": { "message": "タブを再読み込み(&R)" }, + "tabContextMenu_unblockAutoplay_label_multiselected": { "message": "タブのメディアを再生(&L)" }, + "tabContextMenu_mute_label_multiselected": { "message": "タブをミュート(&M)" }, + "tabContextMenu_unmute_label_multiselected": { "message": "タブのミュートを解除(&M)" }, + + "tabContextMenu_unload_label_multiselected": { "message": "%S 個のタブを解放(&U)" }, + + "tabContextMenu_duplicate_label_multiselected": { "message": "選択したタブを複製(&D)" }, + + "tabContextMenu_pin_label_multiselected": { "message": "タブをピン留め(&P)" }, + "tabContextMenu_unpin_label_multiselected": { "message": "タブのピン留めを外す(&P)" }, + + "tabContextMenu_moveTab_label_multiselected": { "message": "選択したタブを移動(&V)" }, + "tabContextMenu_sendTabsToDevice_label_multiselected": { "message": "%S 個のタブを端末へ送信(&N)" }, + + "tabContextMenu_reloadSelected_label": { "message": "選択したタブを再読み込み(&R)" }, + "tabContextMenu_reloadSelected_label_multiselected": { "message": "選択したタブを再読み込み(&R)" }, + + "tabContextMenu_bookmark_label_multiselected": { "message": "タブをブックマーク...(&B)" }, + "tabContextMenu_bookmarkSelected_label": { "message": "選択したタブをブックマーク...(&T)" }, + "tabContextMenu_bookmarkSelected_label_multiselected": { "message": "選択したタブをブックマーク...(&T)" }, + + "tabContextMenu_close_label_multiselected": { "message": "%S 個のタブを閉じる(&C)" }, + + "tabGroupMenu_tab-group-editor-title-create": { "message": "タブグループを作成" }, + "tabGroupMenu_tab-group-editor-title-edit": { "message": "タブグループの管理" }, + "tabGroupMenu_tab-group-editor-name-label": { "message": "グループ名" }, + "tabGroupMenu_tab-group-editor-name-field_placeholder": { "message": "例: ショッピング" }, + "tabGroupMenu_tab-group-editor-cancel_label": { "message": "キャンセル" }, + "tabGroupMenu_tab-group-editor-cancel_accesskey": { "message": "C" }, + "tabGroupMenu_tab-group-editor-color-selector_aria-label": { "message": "タブグループの色" }, + "tabGroupMenu_tab-group-editor-color-selector2-blue": { "message": "青" }, + "tabGroupMenu_tab-group-editor-color-selector2-blue_title": { "message": "青" }, + "tabGroupMenu_tab-group-editor-color-selector2-purple": { "message": "紫" }, + "tabGroupMenu_tab-group-editor-color-selector2-purple_title": { "message": "紫" }, + "tabGroupMenu_tab-group-editor-color-selector2-cyan": { "message": "シアン" }, + "tabGroupMenu_tab-group-editor-color-selector2-cyan_title": { "message": "シアン" }, + "tabGroupMenu_tab-group-editor-color-selector2-orange": { "message": "オレンジ" }, + "tabGroupMenu_tab-group-editor-color-selector2-orange_title": { "message": "オレンジ" }, + "tabGroupMenu_tab-group-editor-color-selector2-yellow": { "message": "黄" }, + "tabGroupMenu_tab-group-editor-color-selector2-yellow_title": { "message": "黄" }, + "tabGroupMenu_tab-group-editor-color-selector2-pink": { "message": "ピンク" }, + "tabGroupMenu_tab-group-editor-color-selector2-pink_title": { "message": "ピンク" }, + "tabGroupMenu_tab-group-editor-color-selector2-green": { "message": "緑" }, + "tabGroupMenu_tab-group-editor-color-selector2-green_title": { "message": "緑" }, + "tabGroupMenu_tab-group-editor-color-selector2-gray": { "message": "グレー" }, + "tabGroupMenu_tab-group-editor-color-selector2-gray_title": { "message": "グレー" }, + "tabGroupMenu_tab-group-editor-color-selector2-red": { "message": "赤" }, + "tabGroupMenu_tab-group-editor-color-selector2-red_title": { "message": "赤" }, + "tabGroupMenu_tab-group-editor-action-new-tab_label": { "message": "グルーブ内に新しいタブを開く" }, + "tabGroupMenu_tab-group-editor-action-new-window_label": { "message": "グループを新しいウィンドウへ移動" }, + "tabGroupMenu_tab-group-editor-action-save_label": { "message": "グループを保存して閉じる" }, + "tabGroupMenu_tab-group-editor-action-ungroup_label": { "message": "グループを解放" }, + "tabGroupMenu_tab-group-editor-action-delete_label": { "message": "グループを削除" }, + "tabGroupMenu_tab-group-editor-done_label": { "message": "完了" }, + "tabGroupMenu_tab-group-editor-done_accesskey": { "message": "D" }, + + + "tryConfirmUsingTST_title": { "message": "競合の検出" }, + "tryConfirmUsingTST_message": { "message": "「Tree Style Tab」拡張機能が検出されました:Waterfoxのツリー垂直タブと競合する恐れがあります。どちらの機能を有効にするか選択してください。" }, + "tryConfirmUsingTST_WS": { "message": "Waterfoxのツリー垂直タブ" }, + "tryConfirmUsingTST_TST": { "message": "Tree Style Tab" }, + "tryConfirmUsingTST_both": { "message": "両方有効にする" }, + "tryConfirmUsingTST_ask": { "message": "競合を検出した場合は再度確認する" }, + "howToActivateAgain_title": { "message": "ツリー垂直タブ無効化" }, + "howToActivateAgain_message": { "message": "Waterfoxのツリー垂直タブを無効化します。機能をもう一度使用する場合は、アドオンマネージャーで「ツリー垂直タブ」を有効化してください。" }, + + "tabsSidebarButton_label": { "message": "ツリー垂直タブ" }, + "tabsSidebarButton_tooltiptext": { "message": "ツリー垂直タブの表示を切り替えます" }, + + "preferencesCategoryName": { "message": "ツリー垂直タブ" }, + "preferencesCategoryTooltipText": { "message": "ツリー垂直タブ" }, + + "preferences_appearanceGroup_caption": { "message": "タブの外観" }, + "preferences_faviconizePinnedTabs_accesskey": { "message": "S" }, + "config_autoStickyTab_caption": { "message": "以下のタブが画面外にスクロールアウトしたときにタブバーの端に貼り付ける" }, + "config_stickyActiveTab_label": { "message": "現在のタブ" }, + "preferences_stickyActiveTab_accesskey": { "message": "K" }, + "config_stickySoundPlayingTab_label": { "message": "音声を再生中のタブ" }, + "preferences_stickySoundPlayingTab_accesskey": { "message": "Y" }, + "config_stickySharingTab_label": { "message": "カメラ/マイク/画面を共有中のタブ" }, + "preferences_stickySharingTab_accesskey": { "message": "G" }, + + "preferences_treeBehaviorGroup_caption": { "message": "タブのツリー表示" }, + "preferences_autoCollapseExpandSubtreeOnAttach_accesskey": { "message": "C" }, + "preferences_autoCollapseExpandSubtreeOnSelect_accesskey": { "message": "X" }, + "preferences_treeDoubleClickBehavior_accesskey": { "message": "D" }, + "preferences_successorTabControlLevel_accesskey": { "message": "H" }, + "preferences_dropLinksOnTabBehavior_accesskey": { "message": "L" }, + + "preferences_autoAttachGroup_caption": { "message": "タブのツリーの自動形成" }, + "preferences_autoAttachOnOpenedWithOwner_accesskey": { "message": "O" }, + "preferences_insertNewTabFromPinnedTabAt_accesskey": { "message": "P" }, + "preferences_autoAttachOnNewTabCommand_accesskey": { "message": "N" }, + "preferences_autoAttachOnNewTabButtonMiddleClick_accesskey": { "message": "M" }, + "preferences_autoAttachOnDuplicated_accesskey": { "message": "U" }, + "preferences_autoAttachSameSiteOrphan_accesskey": { "message": "W" }, + "preferences_autoAttachOnOpenedFromExternal_accesskey": { "message": "A" }, + "preferences_autoAttachOnAnyOtherTrigger_accesskey": { "message": "T" } +} diff --git a/waterfox/browser/components/sidebar/addon-jar.mn b/waterfox/browser/components/sidebar/addon-jar.mn new file mode 100644 index 000000000000..fcfd9ee54121 --- /dev/null +++ b/waterfox/browser/components/sidebar/addon-jar.mn @@ -0,0 +1,15 @@ +# 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/. + +browser.jar: +% resource builtin-addons %builtin-addons/ contentaccessible=yes + builtin-addons/sidebar/_locales/ (./_locales/**) + builtin-addons/sidebar/background/ (./background/**) + builtin-addons/sidebar/common/ (./common/**) + builtin-addons/sidebar/experiments/ (./experiments/**) + builtin-addons/sidebar/extlib/ (./extlib/**) + builtin-addons/sidebar/options/ (./options/**) + builtin-addons/sidebar/resources/ (./resources/**) + builtin-addons/sidebar/sidebar/ (./sidebar/**) + builtin-addons/sidebar/manifest.json (./manifest.json) diff --git a/waterfox/browser/components/sidebar/background/api-tabs-listener.js b/waterfox/browser/components/sidebar/background/api-tabs-listener.js new file mode 100644 index 000000000000..3e9594a77a91 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/api-tabs-listener.js @@ -0,0 +1,1402 @@ +/* ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is the Tree Style Tab. + * + * The Initial Developer of the Original Code is YUKI "Piro" Hiroshi. + * Portions created by the Initial Developer are Copyright (C) 2011-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): YUKI "Piro" Hiroshi + * wanabe + * Tetsuharu OHZEKI + * Xidorn Quan (Firefox 40+ support) + * lv7777 (https://github.com/lv7777) + * + * ***** END LICENSE BLOCK ******/ +'use strict'; + +import { + log as internalLogger, + dumpTab, + toLines, + configs, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from '/common/constants.js'; +import * as SidebarConnection from '/common/sidebar-connection.js'; +import * as TabsInternalOperation from '/common/tabs-internal-operation.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TabsUpdate from '/common/tabs-update.js'; +import * as TreeBehavior from '/common/tree-behavior.js'; +import * as TSTAPI from '/common/tst-api.js'; + +import MetricsData from '/common/MetricsData.js'; +import { Tab, TabGroup } from '/common/TreeItem.js'; +import Window from '/common/Window.js'; + +import * as Tree from './tree.js'; + +function log(...args) { + internalLogger('background/api-tabs-listener', ...args); +} +function logUpdated(...args) { + internalLogger('common/tabs-update', ...args); +} + +let mAppIsActive = false; + +export function init() { + browser.tabs.onActivated.addListener(onActivated); + browser.tabs.onUpdated.addListener(onUpdated); + browser.tabs.onHighlighted.addListener(onHighlighted); + browser.tabs.onCreated.addListener(onCreated); + browser.tabs.onRemoved.addListener(onRemoved); + browser.tabs.onMoved.addListener(onMoved); + browser.tabs.onAttached.addListener(onAttached); + browser.tabs.onDetached.addListener(onDetached); + browser.windows.onCreated.addListener(onWindowCreated); + browser.windows.onRemoved.addListener(onWindowRemoved); + browser.tabGroups.onCreated.addListener(onGroupCreated); + browser.tabGroups.onUpdated.addListener(onGroupUpdated); + browser.tabGroups.onRemoved.addListener(onGroupRemoved); + browser.tabGroups.onMoved.addListener(onGroupMoved); + + browser.windows.getAll({}).then(windows => { + mAppIsActive = windows.some(win => win.focused); + }); +} + +let mPromisedStartedResolver; +let mPromisedStarted = new Promise((resolve, _reject) => { + mPromisedStartedResolver = resolve; +}); + +export function destroy() { + mPromisedStartedResolver = undefined; + mPromisedStarted = undefined; + browser.tabs.onActivated.removeListener(onActivated); + browser.tabs.onUpdated.removeListener(onUpdated); + browser.tabs.onHighlighted.removeListener(onHighlighted); + browser.tabs.onCreated.removeListener(onCreated); + browser.tabs.onRemoved.removeListener(onRemoved); + browser.tabs.onMoved.removeListener(onMoved); + browser.tabs.onAttached.removeListener(onAttached); + browser.tabs.onDetached.removeListener(onDetached); + browser.windows.onCreated.removeListener(onWindowCreated); + browser.windows.onRemoved.removeListener(onWindowRemoved); + browser.tabGroups.onCreated.removeListener(onGroupCreated); + browser.tabGroups.onUpdated.removeListener(onGroupUpdated); + browser.tabGroups.onRemoved.removeListener(onGroupRemoved); + browser.tabGroups.onMoved.removeListener(onGroupMoved); +} + +export function start() { + if (!mPromisedStartedResolver) + return; + mPromisedStartedResolver(); + mPromisedStartedResolver = undefined; + mPromisedStarted = undefined; +} + + +const mTabOperationQueue = []; + +function addTabOperationQueue(metric = null) { + let onCompleted; + const previous = mTabOperationQueue[mTabOperationQueue.length - 1]; + const queue = new Promise((resolve, _aReject) => { + onCompleted = resolve; + }); + queue.then(() => { + mTabOperationQueue.splice(mTabOperationQueue.indexOf(queue), 1); + if (metric) + metric.add('TabOperationQueue proceeded'); + }); + mTabOperationQueue.push(queue); + return [onCompleted, previous]; +} + +function warnTabDestroyedWhileWaiting(tabId, tab) { + if (configs.debug) + console.log(`WARNING: tab ${tabId} is destroyed while waiting. `, tab, new Error().stack); +} + + +async function onActivated(activeInfo) { + if (mPromisedStarted) + await mPromisedStarted; + + TabsStore.activeTabInWindow.set(activeInfo.windowId, Tab.get(activeInfo.tabId)); + + const [onCompleted, previous] = addTabOperationQueue(); + if (!configs.acceleratedTabOperations && previous) + await previous; + + try { + const win = Window.init(activeInfo.windowId); + + const byInternalOperation = win.internallyFocusingTabs.has(activeInfo.tabId); + win.internallyFocusingTabs.delete(activeInfo.tabId); + const byMouseOperation = win.internallyFocusingByMouseTabs.has(activeInfo.tabId); + win.internallyFocusingByMouseTabs.delete(activeInfo.tabId); + const silently = win.internallyFocusingSilentlyTabs.has(activeInfo.tabId); + win.internallyFocusingSilentlyTabs.delete(activeInfo.tabId); + const byTabDuplication = parseInt(win.duplicatingTabsCount) > 0; + + if (!Tab.isTracked(activeInfo.tabId)) + await Tab.waitUntilTracked(activeInfo.tabId); + + const newActiveTab = Tab.get(activeInfo.tabId); + if (!newActiveTab || + !TabsStore.ensureLivingItem(newActiveTab)) { + warnTabDestroyedWhileWaiting(activeInfo.tabId); + onCompleted(); + return; + } + + log('tabs.onActivated: ', newActiveTab); + const oldActiveTabs = TabsInternalOperation.setTabActive(newActiveTab); + const byActiveTabRemove = !activeInfo.previousTabId; + + if (!TabsStore.ensureLivingItem(newActiveTab)) { // it can be removed while waiting + onCompleted(); + warnTabDestroyedWhileWaiting(activeInfo.tabId); + return; + } + + let focusOverridden = Tab.onActivating.dispatch(newActiveTab, { + ...activeInfo, + byActiveTabRemove, + byTabDuplication, + byInternalOperation, + byMouseOperation, + silently + }); + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_ACTIVATING, + windowId: activeInfo.windowId, + tabId: activeInfo.tabId, + byActiveTabRemove, + byTabDuplication, + byInternalOperation, + byMouseOperation, + silently + }); + // don't do await if not needed, to process things synchronously + if (focusOverridden instanceof Promise) + focusOverridden = await focusOverridden; + focusOverridden = focusOverridden === false; + if (focusOverridden) { + onCompleted(); + return; + } + + if (!TabsStore.ensureLivingItem(newActiveTab)) { // it can be removed while waiting + onCompleted(); + warnTabDestroyedWhileWaiting(activeInfo.tabId); + return; + } + + const onActivatedReuslt = Tab.onActivated.dispatch(newActiveTab, { + ...activeInfo, + oldActiveTabs, + byActiveTabRemove, + byTabDuplication, + byInternalOperation, + byMouseOperation, + silently + }); + // don't do await if not needed, to process things synchronously + if (onActivatedReuslt instanceof Promise) + await onActivatedReuslt; + + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_ACTIVATED, + windowId: activeInfo.windowId, + tabId: activeInfo.tabId, + byActiveTabRemove, + byTabDuplication, + byInternalOperation, + byMouseOperation, + silently + }); + onCompleted(); + } + catch(e) { + console.log(e); + onCompleted(); + } +} + +async function onUpdated(tabId, changeInfo, tab) { + if (mPromisedStarted) + await mPromisedStarted; + + if (!Tab.isTracked(tabId)) + await Tab.waitUntilTracked(tabId); + + const [onCompleted, previous] = addTabOperationQueue(); + if (!configs.acceleratedTabOperations && previous) + await previous; + + try { + const updatedTab = Tab.get(tabId); + if (!updatedTab || + !TabsStore.ensureLivingItem(updatedTab)) { + onCompleted(); + warnTabDestroyedWhileWaiting(tabId, updatedTab); + return; + } + + logUpdated('tabs.onUpdated ', tabId, changeInfo, tab, updatedTab); + + if ('url' in changeInfo) { + changeInfo.previousUrl = updatedTab.url; + // On Linux (and possibly on some other environments) the initial page load + // sometimes produces "onUpdated" event with unchanged URL unexpectedly, + // so we should ignure such invalid (uneffective) URL changes. + // See also: https://github.com/piroor/treestyletab/issues/3078 + if (changeInfo.url == 'about:blank' && + changeInfo.previousUrl == changeInfo.url && + changeInfo.status == 'loading') { + delete changeInfo.url; + delete changeInfo.previousUrl; + } + } + const oldState = {}; + for (const key of Object.keys(changeInfo)) { + if (key == 'index') + continue; + if (key in updatedTab) + oldState[key] = updatedTab[key]; + updatedTab[key] = changeInfo[key]; + } + if (changeInfo.url || + changeInfo.status == 'complete') { + // On some edge cases internally changed "favIconUrl" is not + // notified, so we need to check actual favIconUrl manually. + // Known cases are: + // * Transition from "about:privatebrowsing" to "about:blank" + // https://github.com/piroor/treestyletab/issues/1916 + // * Reopen tab by Ctrl-Shift-T + browser.tabs.get(tabId).then(tab => { + if (tab.favIconUrl != updatedTab.favIconUrl) + onUpdated(tabId, { favIconUrl: tab.favIconUrl }, tab); + }).catch(ApiTabs.createErrorSuppressor( + ApiTabs.handleMissingTabError // the tab can be closed while waiting + )); + } + + TabsUpdate.updateTab(updatedTab, changeInfo, { tab, old: oldState }); + + const onUpdatedResult = Tab.onUpdated.dispatch(updatedTab, changeInfo); + // don't do await if not needed, to process things synchronously + if (onUpdatedResult instanceof Promise) + await onUpdatedResult; + + onCompleted(); + } + catch(e) { + console.log(e); + onCompleted(); + } +} + +const mTabsHighlightedTimers = new Map(); +const mLastHighlightedCount = new Map(); +async function onHighlighted(highlightInfo) { + if (mPromisedStarted) + await mPromisedStarted; + + // ignore internally highlighted tabs - they are already handled + const win = TabsStore.windows.get(highlightInfo.windowId); + const unifiedHighlightedTabs = new Set([...win.highlightingTabs, ...highlightInfo.tabIds]); + if (unifiedHighlightedTabs.size == win.highlightingTabs.size) { + log(`Internal highlighting is in progress: ${Math.ceil(highlightInfo.tabIds.length / win.highlightingTabs.size * 100)} %`); + if (highlightInfo.tabIds.length == win.highlightingTabs.size) { + win.highlightingTabs.clear(); + log('Internal highlighting done.'); + } + return; + } + + let timer = mTabsHighlightedTimers.get(highlightInfo.windowId); + if (timer) + clearTimeout(timer); + if ((mLastHighlightedCount.get(highlightInfo.windowId) || 0) <= 1 && + highlightInfo.tabIds.length == 1) { + // simple active tab switching + TabsUpdate.updateTabsHighlighted(highlightInfo); + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_HIGHLIGHTED_TABS_CHANGED, + windowId: highlightInfo.windowId, + tabIds: highlightInfo.tabIds + }); + return; + } + timer = setTimeout(() => { + mTabsHighlightedTimers.delete(highlightInfo.windowId); + TabsUpdate.updateTabsHighlighted(highlightInfo); + mLastHighlightedCount.set(highlightInfo.windowId, highlightInfo.tabIds.length); + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_HIGHLIGHTED_TABS_CHANGED, + windowId: highlightInfo.windowId, + tabIds: highlightInfo.tabIds + }); + }, configs.delayToApplyHighlightedState); + mTabsHighlightedTimers.set(highlightInfo.windowId, timer); +} + +async function onCreated(tab) { + const metric = new MetricsData(`tab ${tab.id} (tabs.onCreated)`); + + if (mPromisedStarted) { + await mPromisedStarted; + metric.add('mPromisedStarted resolved'); + } + + log('tabs.onCreated: ', dumpTab(tab)); + + // Cache the initial index for areTabsFromOtherDeviceWithInsertAfterCurrent()@handle-tab-bunches.js + // See also: https://github.com/piroor/treestyletab/issues/2419 + tab.$indexOnCreated = tab.index; + // Cache the initial windowId for Tab.onUpdated listner@handle-new-tabs.js + tab.$windowIdOnCreated = tab.windowId; + + return onNewTabTracked(tab, { trigger: 'tabs.onCreated', metric }); +} + +async function onNewTabTracked(tab, info) { + const win = Window.init(tab.windowId); + const bypassTabControl = win.bypassTabControlCount > 0; + const isNewTabCommandTab = win.toBeOpenedNewTabCommandTab > 0; + const positionedBySelf = win.toBeOpenedTabsWithPositions > 0; + const openedWithCookieStoreId = win.toBeOpenedTabsWithCookieStoreId > 0; + const duplicatedInternally = win.duplicatingTabsCount > 0; + const maybeOrphan = win.toBeOpenedOrphanTabs > 0; + const activeTab = Tab.getActiveTab(win.id); + const fromExternal = !mAppIsActive && !tab.openerTabId; + const initialOpenerTabId = tab.openerTabId; + const metric = info.metric || new MetricsData(`tab ${tab.id}`); + + // New tab's index can become invalid because the value of "index" is same to + // the one given to browser.tabs.create() (new tab) or the original index + // (restored tab) instead of its actual index. + // (By the way, any pinned tab won't be opened after the first unpinned tab, + // and any unpinned tab won't be opened before the last pinned tab. On such + // cases Firefox automatically fixup the index regardless they are newly + // opened ore restored, so we don't need to care such cases.) + // See also: + // https://github.com/piroor/treestyletab/issues/2131 + // https://github.com/piroor/treestyletab/issues/2216 + // https://bugzilla.mozilla.org/show_bug.cgi?id=1541748 + tab.index = Math.max(0, Math.min(tab.index, win.tabs.size)); + tab.reindexedBy = `onNewTabTracked (${tab.index})`; + + // New tab from a bookmark or external apps always have its URL as the title + // (but the scheme part is missing.) + tab.$possibleInitialUrl = tab.title; + + // We need to track new tab after getting old active tab. Otherwise, this + // operation updates the latest active tab in the window amd it becomes + // impossible to know which tab was previously active. + tab = Tab.track(tab); + metric.add('tracked'); + + if (isNewTabCommandTab) + tab.$isNewTabCommandTab = true; + + if (info.trigger == 'tabs.onCreated') + tab.$TST.addState(Constants.kTAB_STATE_CREATING); + if (tab.$TST.isNewTabCommandTab) + tab.$TST.addState(Constants.kTAB_STATE_NEW_TAB_COMMAND_TAB); + if (fromExternal) + tab.$TST.addState(Constants.kTAB_STATE_FROM_EXTERNAL); + if (tab.$TST.hasFirefoxViewOpener) + tab.$TST.addState(Constants.kTAB_STATE_FROM_FIREFOX_VIEW); + + const mayBeReplacedWithContainer = tab.$TST.mayBeReplacedWithContainer; + log(`onNewTabTracked(${dumpTab(tab)}): `, tab, { win, positionedBySelf, mayBeReplacedWithContainer, duplicatedInternally, maybeOrphan, activeTab }); + + Tab.onBeforeCreate.dispatch(tab, { + positionedBySelf, + openedWithCookieStoreId, + mayBeReplacedWithContainer, + maybeOrphan, + activeTab, + fromExternal + }); + metric.add('Tab.onBeforeCreate proceeded'); + + if (Tab.needToWaitTracked(tab.windowId, { exceptionTabId: tab.id })) { + await Tab.waitUntilTrackedAll(tab.windowId, { exceptionTabId: tab.id }); + metric.add('Tab.waitUntilTrackedAll resolved'); + } + + const [onCompleted, previous] = addTabOperationQueue(metric); + if (!configs.acceleratedTabOperations && previous) { + await previous; + metric.add('previous resolved'); + } + + log(`onNewTabTracked(${dumpTab(tab)}): start to create tab element`); + + // Cached tree information may be expired when there are multiple new tabs + // opened at just same time and some of others are attached on listeners of + // "onCreating" and other points. Thus we need to refresh cached information + // dynamically. + // See also: https://github.com/piroor/treestyletab/issues/2419 + let treeForActionDetection; + const onTreeModified = (_child, _info) => { + if (!treeForActionDetection || + !TabsStore.ensureLivingItem(tab)) + return; + treeForActionDetection = Tree.snapshotForActionDetection(tab); + log('Tree modification is detected while waiting. Cached tree for action detection is updated: ', treeForActionDetection); + }; + // We should refresh ceched information only when tabs are creaetd and + // attached, because the cacheed information was originally introduced for + // failsafe around problems from tabs closed while waiting. + Tree.onAttached.addListener(onTreeModified); + metric.add('Tree.onAttached proceeded'); + + try { + tab = Tab.init(tab, { inBackground: false }); + metric.add('init'); + + const nextTab = Tab.getTabAt(win.id, tab.index); + metric.add('nextTab'); + + // We need to update "active" state of a new active tab immediately. + // Attaching of initial child tab (this new tab may become it) to an + // existing tab may produce collapsing of existing tree, and a + // collapsing tree may have the old active tab. On such cases TST + // tries to move focus to a nearest visible ancestor, instead of this + // new active tab. + // See also: https://github.com/piroor/treestyletab/issues/2155 + if (tab.active) { + TabsInternalOperation.setTabActive(tab); + metric.add('setTabActive'); + } + + const uniqueId = await tab.$TST.promisedUniqueId; + metric.add('uniqueId resolved'); + + if (!TabsStore.ensureLivingItem(tab)) { // it can be removed while waiting + onCompleted(uniqueId); + tab.$TST.rejectOpened(); + Tab.untrack(tab.id); + warnTabDestroyedWhileWaiting(tab.id, tab); + metric.add('untracked'); + log(' tab is untracked while tracking, metric: ', metric); + return; + } + + TabsUpdate.updateTab(tab, tab, { + forceApply: true + }); + metric.add('TabsUpdate.updateTab proceeded'); + + const duplicated = duplicatedInternally || uniqueId.duplicated; + const restored = uniqueId.restored; + const skipFixupTree = !nextTab; + log(`onNewTabTracked(${dumpTab(tab)}): `, { duplicated, restored, skipFixupTree }); + if (duplicated) + tab.$TST.addState(Constants.kTAB_STATE_DUPLICATED); + + const maybeNeedToFixupTree = ( + (info.mayBeReplacedWithContainer || + (!duplicated && + !restored && + !skipFixupTree)) && + !info.positionedBySelf + ); + // Tabs can be removed and detached while waiting, so cache them here for `detectTabActionFromNewPosition()`. + // This operation takes too much time so it should be skipped if unnecessary. + // See also: https://github.com/piroor/treestyletab/issues/2278#issuecomment-521534290 + treeForActionDetection = maybeNeedToFixupTree ? Tree.snapshotForActionDetection(tab) : null; + + if (bypassTabControl) + win.bypassTabControlCount--; + if (isNewTabCommandTab) + win.toBeOpenedNewTabCommandTab--; + if (positionedBySelf) + win.toBeOpenedTabsWithPositions--; + if (openedWithCookieStoreId) + win.toBeOpenedTabsWithCookieStoreId--; + if (maybeOrphan) + win.toBeOpenedOrphanTabs--; + if (duplicatedInternally) + win.duplicatingTabsCount--; + + if (restored) { + win.restoredCount = win.restoredCount || 0; + win.restoredCount++; + if (!win.promisedAllTabsRestored) { + log(`onNewTabTracked(${dumpTab(tab)}): Maybe starting to restore window`); + win.promisedAllTabsRestored = (new Promise((resolve, _aReject) => { + let lastCount = win.restoredCount; + const timer = setInterval(() => { + if (lastCount != win.restoredCount) { + lastCount = win.restoredCount; + return; + } + clearTimeout(timer); + win.promisedAllTabsRestored = null; + win.restoredCount = 0; + log('All tabs are restored'); + resolve(lastCount); + }, 200); + })).then(async lastCount => { + await Tab.onWindowRestoring.dispatch({ + windowId: tab.windowId, + restoredCount: lastCount, + }); + metric.add('Tab.onWindowRestoring proceeded'); + return lastCount; + }); + } + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_RESTORING, + tabId: tab.id, + windowId: tab.windowId + }); + await win.promisedAllTabsRestored; + log(`onNewTabTracked(${dumpTab(tab)}): continued for restored tab`); + metric.add('win.promisedAllTabsRestored resolved'); + } + if (!TabsStore.ensureLivingItem(tab)) { + log(`onNewTabTracked(${dumpTab(tab)}): => aborted`); + onCompleted(uniqueId); + tab.$TST.rejectOpened(); + Tab.untrack(tab.id); + warnTabDestroyedWhileWaiting(tab.id, tab); + Tree.onAttached.removeListener(onTreeModified); + metric.add('untracked'); + log(' tab is untracked while tracking after updated, metric: ', metric); + return; + } + + let moved = Tab.onCreating.dispatch(tab, { + bypassTabControl, + positionedBySelf, + openedWithCookieStoreId, + mayBeReplacedWithContainer, + maybeOrphan, + restored, + duplicated, + duplicatedInternally, + activeTab, + fromExternal + }); + metric.add('Tab.onCreating proceeded'); + // don't do await if not needed, to process things synchronously + if (moved instanceof Promise) { + moved = await moved; + metric.add('moved resolved'); + } + moved = moved === false; + + if (!TabsStore.ensureLivingItem(tab)) { + log(`onNewTabTracked(${dumpTab(tab)}): => aborted`); + onCompleted(uniqueId); + tab.$TST.rejectOpened(); + Tab.untrack(tab.id); + warnTabDestroyedWhileWaiting(tab.id, tab); + Tree.onAttached.removeListener(onTreeModified); + metric.add('untracked'); + log(' tab is untracked while tracking after moved, metric: ', metric); + return; + } + + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_CREATING, + windowId: tab.windowId, + tabId: tab.id, + tab: tab.$TST.sanitized, + order: win.order, + maybeMoved: moved + }); + log(`onNewTabTracked(${dumpTab(tab)}): moved = `, moved); + metric.add('kCOMMAND_NOTIFY_TAB_CREATING notified'); + + if (TabsStore.ensureLivingItem(tab)) { // it can be removed while waiting + win.openingTabs.add(tab.id); + setTimeout(() => { // because window.requestAnimationFrame is decelerate for an invisible document. + if (!TabsStore.windows.get(tab.windowId)) // it can be removed while waiting + return; + win.openingTabs.delete(tab.id); + }, 0); + } + + if (!TabsStore.ensureLivingItem(tab)) { // it can be removed while waiting + onCompleted(uniqueId); + tab.$TST.rejectOpened(); + Tab.untrack(tab.id); + warnTabDestroyedWhileWaiting(tab.id, tab); + Tree.onAttached.removeListener(onTreeModified); + metric.add('untracked'); + log(' tab is untracked while tracking after notified to sidebar, metric: ', metric); + return; + } + + log(`onNewTabTracked(${dumpTab(tab)}): uniqueId = `, uniqueId); + + Tab.onCreated.dispatch(tab, { + bypassTabControl, + positionedBySelf, + mayBeReplacedWithContainer, + movedBySelfWhileCreation: moved, + skipFixupTree, + restored, + duplicated, + duplicatedInternally, + originalTab: duplicated && Tab.get(uniqueId.originalTabId), + treeForActionDetection, + fromExternal + }); + tab.$TST.resolveOpened(); + metric.add('Tab.onCreated proceeded'); + + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_CREATED, + windowId: tab.windowId, + tabId: tab.id, + collapsed: tab.$TST.collapsed, // it may be really collapsed by some reason (for example, opened under a collapsed tree), not just for "created" animation! + active: tab.active, + maybeMoved: moved + }); + metric.add('kCOMMAND_NOTIFY_TAB_CREATED notified'); + + if (!duplicated && + restored) { + tab.$TST.addState(Constants.kTAB_STATE_RESTORED); + Tab.onRestored.dispatch(tab); + checkRecycledTab(win.id); + } + + onCompleted(uniqueId); + tab.$TST.removeState(Constants.kTAB_STATE_CREATING); + metric.add('remove creating state'); + + if (TSTAPI.hasListenerForMessageType(TSTAPI.kNOTIFY_NEW_TAB_PROCESSED)) { + const cache = {}; + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_NEW_TAB_PROCESSED, + tab, + originalTab: duplicated && Tab.get(uniqueId.originalTabId), + restored, + duplicated, + fromExternal, + }, { tabProperties: ['tab', 'originalTab'], cache }).catch(_error => {}); + TSTAPI.clearCache(cache); + metric.add('API broadcaster'); + } + + // tab can be changed while creating! + const renewedTab = await browser.tabs.get(tab.id).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + metric.add('renewedTab'); + if (!renewedTab) { + log(`onNewTabTracked(${dumpTab(tab)}): tab ${tab.id} is closed while tracking`); + onCompleted(uniqueId); + tab.$TST.rejectOpened(); + Tab.untrack(tab.id); + warnTabDestroyedWhileWaiting(tab.id, tab); + Tree.onAttached.removeListener(onTreeModified); + metric.add('untracked'); + log(' tab is untracked while tracking after created, metric: ', metric); + return; + } + + const updatedOpenerTabId = tab.openerTabId; + const changedProps = {}; + for (const key of Object.keys(renewedTab)) { + const value = renewedTab[key]; + if (tab[key] == value) + continue; + if (key == 'openerTabId' && + info.trigger == 'tabs.onAttached' && + value != tab.openerTabId && + tab.openerTabId == tab.$TST.temporaryMetadata.get('updatedOpenerTabId')) { + log(`openerTabId of ${tab.id} is different from the raw value but it has been updated by TST while attaching, so don't detect as updated for now`); + continue; + } + changedProps[key] = value; + } + + // When the active tab is duplicated, Firefox creates a duplicated tab + // with its `openerTabId` filled with the ID of the source tab. + // It is the `initialOpenerTabId`. + // On the other hand, TST may attach the duplicated tab to any other + // parent while it is initializing, based on a configuration + // `configs.autoAttachOnDuplicated`. It is the `updatedOpenerTabId`. + // At this scenario `renewedTab.openerTabId` becomes `initialOpenerTabId` + // and `updatedOpenerTabId` is lost. + // Thus we need to re-apply `updatedOpenerTabId` as the `openerTabId` of + // the tab again, to keep the tree structure managed by TST. + // See also: https://github.com/piroor/treestyletab/issues/2388 + if ('openerTabId' in changedProps) { + log(`openerTabId of ${tab.id} is changed while creating: ${tab.openerTabId} (changed by someone) => ${changedProps.openerTabId} (original) `, configs.debug && new Error().stack); + if (duplicated && + tab.active && + changedProps.openerTabId == initialOpenerTabId && + changedProps.openerTabId != updatedOpenerTabId) { + log(`restore original openerTabId of ${tab.id} for duplicated active tab: ${updatedOpenerTabId}`); + delete changedProps.openerTabId; + browser.tabs.update(tab.id, { openerTabId: updatedOpenerTabId }); + } + metric.add('changed openerTabId handled'); + } + + if (Object.keys(renewedTab).length > 0) { + onUpdated(tab.id, changedProps, renewedTab); + metric.add('onUpdated notified'); + } + + const currentActiveTab = Tab.getActiveTab(tab.windowId); + if (renewedTab.active && + currentActiveTab.id != tab.id) { + onActivated({ + tabId: tab.id, + windowId: tab.windowId, + previousTabId: currentActiveTab.id + }); + metric.add('onActivated notified'); + } + + tab.$TST.memorizeNeighbors('newly tracked'); + tab.$TST.unsafePreviousTab?.$TST?.memorizeNeighbors('unsafePreviousTab'); + tab.$TST.unsafeNextTab?.$TST?.memorizeNeighbors('unsafeNextTab'); + + Tree.onAttached.removeListener(onTreeModified); + metric.add('Tree.onAttached proceeded'); + + log('metric on finish: ', metric); + return tab; + } + catch(error) { + console.log(error, error.stack); + onCompleted(); + tab.$TST.removeState(Constants.kTAB_STATE_CREATING); + Tree.onAttached.removeListener(onTreeModified); + metric.add('error handled ', error); + log('metric on error: ', metric); + } +} + +// "Recycled tab" is an existing but reused tab for session restoration. +function checkRecycledTab(windowId) { + const possibleRecycledTabs = Tab.getRecycledTabs(windowId); + log(`Detecting recycled tabs`); + for (const tab of possibleRecycledTabs) { + if (!TabsStore.ensureLivingItem(tab)) + continue; + const currentId = tab.$TST.uniqueId.id; + tab.$TST.updateUniqueId().then(uniqueId => { + if (!TabsStore.ensureLivingItem(tab) || + !uniqueId.restored || + uniqueId.id == currentId || + Constants.kTAB_STATE_RESTORED in tab.$TST.states) + return; + log('A recycled tab is detected: ', dumpTab(tab)); + tab.$TST.addState(Constants.kTAB_STATE_RESTORED); + Tab.onRestored.dispatch(tab); + }); + } +} + +async function onRemoved(tabId, removeInfo) { + Tree.markTabIdAsUnattachable(tabId); + + if (mPromisedStarted) + await mPromisedStarted; + + log('tabs.onRemoved: ', tabId, removeInfo); + const win = Window.init(removeInfo.windowId); + const byInternalOperation = win.internalClosingTabs.has(tabId); + const preventEntireTreeBehavior = win.keepDescendantsTabs.has(tabId); + + win.internalMovingTabs.delete(tabId); + win.alreadyMovedTabs.delete(tabId); + win.internalClosingTabs.delete(tabId); + win.keepDescendantsTabs.delete(tabId); + win.highlightingTabs.delete(tabId); + win.tabsToBeHighlightedAlone.delete(tabId); + + win.internallyFocusingTabs.delete(tabId); + win.internallyFocusingByMouseTabs.delete(tabId); + win.internallyFocusingSilentlyTabs.delete(tabId); + + if (Tab.needToWaitTracked(removeInfo.windowId)) + await Tab.waitUntilTrackedAll(removeInfo.windowId); + + const [onCompleted, previous] = addTabOperationQueue(); + if (!configs.acceleratedTabOperations && previous) + await previous; + + try { + const oldTab = Tab.get(tabId); + if (!oldTab) { + onCompleted(); + return; + } + + log('tabs.onRemoved, tab is found: ', oldTab, `openerTabId=${oldTab.openerTabId}`); + + const nearestTabs = [oldTab.$TST.unsafePreviousTab, oldTab.$TST.unsafeNextTab]; + + // remove from "highlighted tabs" cache immediately, to prevent misdetection for "multiple highlighted". + TabsStore.removeHighlightedTab(oldTab); + TabsStore.removeGroupTab(oldTab); + + TabsStore.addRemovedTab(oldTab); + + removeInfo = { + ...removeInfo, + byInternalOperation, + preventEntireTreeBehavior, + oldChildren: oldTab.$TST.children, + oldParent: oldTab.$TST.parent, + context: Constants.kPARENT_TAB_OPERATION_CONTEXT_CLOSE + }; + + if (!removeInfo.isWindowClosing) { + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_REMOVING, + windowId: oldTab.windowId, + tabId: oldTab.id, + isWindowClosing: removeInfo.isWindowClosing, + byInternalOperation, + preventEntireTreeBehavior, + }); + } + + const onRemovingResult = Tab.onRemoving.dispatch(oldTab, { + ...removeInfo, + byInternalOperation, + preventEntireTreeBehavior, + }); + // don't do await if not needed, to process things synchronously + if (onRemovingResult instanceof Promise) + await onRemovingResult; + + // The removing tab may be attached to tree/someone attached to the removing tab. + // We need to clear them by onRemoved handlers. + removeInfo.oldChildren = oldTab.$TST.children; + removeInfo.oldParent = oldTab.$TST.parent; + oldTab.$TST.addState(Constants.kTAB_STATE_REMOVING); + TabsStore.addRemovingTab(oldTab); + + TabsStore.windows.get(removeInfo.windowId).detachTab(oldTab.id, { + toBeRemoved: true + }); + + const onRemovedReuslt = Tab.onRemoved.dispatch(oldTab, removeInfo); + // don't do await if not needed, to process things synchronously + if (onRemovedReuslt instanceof Promise) + await onRemovedReuslt; + + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_REMOVED, + windowId: oldTab.windowId, + tabId: oldTab.id, + isWindowClosing: removeInfo.isWindowClosing, + byInternalOperation, + preventEntireTreeBehavior, + }); + oldTab.$TST.destroy(); + + for (const tab of nearestTabs) { + tab?.$TST?.memorizeNeighbors('neighbor of closed tab'); + } + + onCompleted(); + } + catch(e) { + console.log(e); + onCompleted(); + } + finally { + Tree.clearUnattachableTabId(tabId); + } +} + +async function onMoved(tabId, moveInfo) { + if (mPromisedStarted) + await mPromisedStarted; + + const win = Window.init(moveInfo.windowId); + + // Cancel in-progress highlighting, because tabs.highlight() uses old indices of tabs. + win.tabsMovedWhileHighlighting = true; + + // Firefox may move the tab between TabsMove.moveTabsInternallyBefore/After() + // and TabsMove.syncTabsPositionToApiTabs(). We should treat such a movement + // as an "internal" operation also, because we need to suppress "move back" + // and other fixup operations around tabs moved by foreign triggers, on such + // cases. Don't mind, the tab will be rearranged again by delayed + // TabsMove.syncTabsPositionToApiTabs() anyway! + const internalExpectedIndex = win.internalMovingTabs.get(tabId); + const maybeInternalOperation = internalExpectedIndex < 0 || internalExpectedIndex == moveInfo.toIndex; + if (maybeInternalOperation) + log(`tabs.onMoved: ${tabId} is detected as moved internally`); + + if (!Tab.isTracked(tabId)) + await Tab.waitUntilTracked(tabId); + if (Tab.needToWaitMoved(moveInfo.windowId)) + await Tab.waitUntilMovedAll(moveInfo.windowId); + + const [onCompleted, previous] = addTabOperationQueue(); + if (!configs.acceleratedTabOperations && previous) + await previous; + + try { + const finishMoving = Tab.get(tabId).$TST.startMoving(); + const completelyMoved = () => { finishMoving(); onCompleted() }; + + /* When a tab is pinned, tabs.onMoved may be notified before + tabs.onUpdated(pinned=true) is notified. As the result, + descendant tabs are unexpectedly moved to the top of the + tab bar to follow their parent pinning tab. To avoid this + problem, we have to wait for a while with this "async" and + do following processes after the tab is completely pinned. */ + const movedTab = Tab.get(tabId); + if (!movedTab) { + if (win.internalMovingTabs.has(tabId)) + win.internalMovingTabs.delete(tabId); + completelyMoved(); + warnTabDestroyedWhileWaiting(tabId, movedTab); + return; + } + + let oldPreviousTab = movedTab.hidden ? movedTab.$TST.unsafePreviousTab : movedTab.$TST.previousTab; + let oldNextTab = movedTab.hidden ? movedTab.$TST.unsafeNextTab : movedTab.$TST.nextTab; + if (movedTab.index != moveInfo.toIndex || + (oldPreviousTab?.index == movedTab.index - 1) || + (oldNextTab?.index == movedTab.index + 1)) { + // already moved + oldPreviousTab = Tab.getTabAt(moveInfo.windowId, moveInfo.toIndex < moveInfo.fromIndex ? moveInfo.fromIndex : moveInfo.fromIndex - 1); + oldNextTab = Tab.getTabAt(moveInfo.windowId, moveInfo.toIndex < moveInfo.fromIndex ? moveInfo.fromIndex + 1 : moveInfo.fromIndex); + if (oldPreviousTab?.id == movedTab.id) + oldPreviousTab = Tab.getTabAt(moveInfo.windowId, moveInfo.toIndex < moveInfo.fromIndex ? moveInfo.fromIndex - 1 : moveInfo.fromIndex - 2); + if (oldNextTab?.id == movedTab.id) + oldNextTab = Tab.getTabAt(moveInfo.windowId, moveInfo.toIndex < moveInfo.fromIndex ? moveInfo.fromIndex : moveInfo.fromIndex - 1); + } + + const expectedIndex = win.alreadyMovedTabs.get(tabId); + const alreadyMoved = expectedIndex < 0 || expectedIndex == moveInfo.toIndex; + if (win.alreadyMovedTabs.has(tabId)) + win.alreadyMovedTabs.delete(tabId); + + const extendedMoveInfo = { + ...moveInfo, + byInternalOperation: maybeInternalOperation, + alreadyMoved, + oldPreviousTab, + oldNextTab, + // Multiselected tabs can be moved together in bulk, by drag and drop + // in the horizontal tab bar, or addons like + // https://addons.mozilla.org/firefox/addon/move-tab-hotkeys/ + movedInBulk: !maybeInternalOperation && (movedTab.$TST.multiselected || movedTab.$TST.movedInBulk), + }; + log('tabs.onMoved: ', movedTab, extendedMoveInfo); + + let canceled = Tab.onMoving.dispatch(movedTab, extendedMoveInfo); + // don't do await if not needed, to process things synchronously + if (canceled instanceof Promise) + await canceled; + canceled = canceled === false; + if (!canceled && + TabsStore.ensureLivingItem(movedTab)) { // it is removed while waiting + let newNextIndex = extendedMoveInfo.toIndex; + if (extendedMoveInfo.fromIndex < newNextIndex) + newNextIndex++; + const nextTab = Tab.getTabAt(moveInfo.windowId, newNextIndex); + extendedMoveInfo.nextTab = nextTab; + if (!alreadyMoved && + movedTab.$TST.nextTab != nextTab) { + if (nextTab) { + if (nextTab.index > movedTab.index) + movedTab.index = nextTab.index - 1; + else + movedTab.index = nextTab.index; + } + else { + movedTab.index = win.tabs.size - 1 + } + movedTab.reindexedBy = `tabs.onMoved (${movedTab.index})`; + win.trackTab(movedTab); + log('Tab nodes rearranged by tabs.onMoved listener:\n'+(!configs.debug ? '' : + toLines(Array.from(win.getOrderedTabs()), + tab => ` - ${tab.index}: ${tab.id}${tab.id == movedTab.id ? '[MOVED]' : ''}`)), + { moveInfo }); + } + const onMovedResult = Tab.onMoved.dispatch(movedTab, extendedMoveInfo); + // don't do await if not needed, to process things synchronously + if (onMovedResult instanceof Promise) + await onMovedResult; + if (!alreadyMoved) + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_MOVED, + windowId: movedTab.windowId, + tabId: movedTab.id, + fromIndex: moveInfo.fromIndex, + toIndex: movedTab.index, + nextTabId: nextTab?.id, + }); + } + if (win.internalMovingTabs.has(tabId)) + win.internalMovingTabs.delete(tabId); + completelyMoved(); + + movedTab.$TST.memorizeNeighbors('moved'); + movedTab.$TST.unsafePreviousTab?.$TST?.memorizeNeighbors('unsafePreviousTab'); + movedTab.$TST.unsafeNextTab?.$TST?.memorizeNeighbors('unsafeNextTab'); + + oldPreviousTab?.$TST?.memorizeNeighbors('oldPreviousTab'); + oldNextTab?.$TST?.memorizeNeighbors('oldNextTab'); + } + catch(e) { + console.log(e); + onCompleted(); + } +} + +const mTreeInfoForTabsMovingAcrossWindows = new Map(); + +async function onAttached(tabId, attachInfo) { + if (mPromisedStarted) + await mPromisedStarted; + + const [onCompleted, previous] = addTabOperationQueue(); + if (!configs.acceleratedTabOperations && previous) + await previous; + + try { + log('tabs.onAttached, id: ', tabId, attachInfo); + let tab = Tab.get(tabId); + let attachedTab = await browser.tabs.get(tabId).catch(error => { + console.error(error); + return null; + }); + if (!attachedTab) { + // We sometimes fail to get window and tab via API if it is opened + // as a popup window but not exposed to API yet. So for safety + // we should retry for a while. + // See also: https://github.com/piroor/treestyletab/issues/3311 + const newWindow = await browser.windows.get(attachInfo.newWindowId, { populate: true }).then(_error => null); + attachedTab = newWindow?.tabs.find(tab => tab.id == tabId); + if (!newWindow || !attachedTab) { + if (!('$TST_retryCount' in attachInfo)) + attachInfo.$TST_retryCount = 0; + if (attachInfo.$TST_retryCount < 10) { + attachInfo.$TST_retryCount++; + setTimeout(() => onAttached(tabId, attachInfo), 0); // because window.requestAnimationFrame is decelerate for an invisible document. + return; + } + console.log(`tabs.onAttached: the tab ${tabId} or the window ${attachInfo.newWindowId} is already closed. `); + onCompleted(); + return; + } + } + + if (!tab) { + log(`tabs.onAttached: Moved tab ${tabId} is not tracked yet.`); + const newWindow = await browser.windows.get(attachInfo.newWindowId, { populate: true }).then(_error => null); + attachedTab = newWindow?.tabs.find(tab => tab.id == tabId); + if (!attachedTab) { + console.log(`tabs.onAttached: the tab ${tabId} is already closed.`); + onCompleted(); + return; + } + onWindowCreated(newWindow); + await onNewTabTracked(attachedTab, { trigger: 'tabs.onAttached' }); + tab = Tab.get(tabId); + } + + tab.windowId = attachInfo.newWindowId + tab.index = attachedTab.index; + tab.reindexedBy = `tabs.onAttached (${tab.index})`; + + if (tab.groupId != -1) { + // tabGroups.onMoved may be notified after all tabs are moved across windows, + // but we need to use group information in the destination window, thus we + // simulate native events here. + TabsStore.addNativelyGroupedTab(tab, attachInfo.newWindowId); + if (TabGroup.getMembers(tab.groupId, { windowId: attachInfo.newWindowId }).length == 1) { + const group = TabGroup.get(tab.groupId); + if (group) { + TabsStore.windows.get(attachInfo.newWindowId).tabGroups.set(group.id, group); + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_GROUP_CREATED, + windowId: attachInfo.newWindowId, + group: group.$TST.sanitized, + }); + } + } + } + + TabsInternalOperation.clearOldActiveStateInWindow(attachInfo.newWindowId); + const info = { + ...attachInfo, + ...mTreeInfoForTabsMovingAcrossWindows.get(tabId) + }; + mTreeInfoForTabsMovingAcrossWindows.delete(tabId); + + const win = TabsStore.windows.get(attachInfo.newWindowId); + await onNewTabTracked(tab, { trigger: 'tabs.onAttached' }); + const byInternalOperation = win.toBeAttachedTabs.has(tab.id); + if (byInternalOperation) + win.toBeAttachedTabs.delete(tab.id); + info.byInternalOperation = info.byInternalOperation || byInternalOperation; + + if (!byInternalOperation) { // we should process only tabs attached by others. + const onAttachedResult = Tab.onAttached.dispatch(tab, info); + // don't do await if not needed, to process things synchronously + if (onAttachedResult instanceof Promise) + await onAttachedResult; + } + + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_ATTACHED_TO_WINDOW, + windowId: attachInfo.newWindowId, + tabId + }); + + onCompleted(); + } + catch(e) { + console.log(e); + onCompleted(); + } +} + +async function onDetached(tabId, detachInfo) { + if (mPromisedStarted) + await mPromisedStarted; + + const [onCompleted, previous] = addTabOperationQueue(); + if (!configs.acceleratedTabOperations && previous) + await previous; + + try { + log('tabs.onDetached, id: ', tabId, detachInfo); + const oldTab = Tab.get(tabId); + if (!oldTab) { + onCompleted(); + return; + } + + const oldWindow = TabsStore.windows.get(detachInfo.oldWindowId); + const byInternalOperation = oldWindow.toBeDetachedTabs.has(tabId); + if (byInternalOperation) + oldWindow.toBeDetachedTabs.delete(tabId); + + const descendants = oldTab.$TST.descendants; + const info = { + ...detachInfo, + byInternalOperation, + trigger: 'tabs.onDetached', + windowId: detachInfo.oldWindowId, + structure: TreeBehavior.getTreeStructureFromTabs([oldTab, ...descendants]), + descendants + }; + const alreadyMovedAcrossWindows = Array.from(mTreeInfoForTabsMovingAcrossWindows.values(), info => info.descendants.map(tab => tab.id)).some(tabIds => tabIds.includes(tabId)); + if (!alreadyMovedAcrossWindows) + mTreeInfoForTabsMovingAcrossWindows.set(tabId, info); + + if (oldTab.groupId != -1) { + TabsStore.removeNativelyGroupedTab(oldTab, detachInfo.oldWindowId); + if (TabGroup.getMembers(oldTab.groupId, { windowId: detachInfo.oldWindowId }).length == 0) { + const group = TabGroup.get(oldTab.groupId); + if (group) { + TabsStore.windows.get(detachInfo.oldWindowId).tabGroups.delete(group.id); + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_GROUP_REMOVED, + windowId: detachInfo.oldWindowId, + group: group.$TST.sanitized, + }); + } + } + } + + if (!byInternalOperation) // we should process only tabs detached by others. + Tab.onDetached.dispatch(oldTab, info); + + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_DETACHED_FROM_WINDOW, + windowId: detachInfo.oldWindowId, + tabId, + wasPinned: oldTab.pinned + }); + // We need to notify this to some conetnt scripts, to destroy themselves. + try { + browser.tabs.sendMessage(tabId, { + type: Constants.kCOMMAND_NOTIFY_TAB_DETACHED_FROM_WINDOW, + }).catch(_error => {}); + } + catch (_error) { + } + + TabsStore.addRemovedTab(oldTab); + oldWindow.detachTab(oldTab.id, { + toBeDetached: true + }); + if (!TabsStore.getCurrentWindowId() && // only in the background page - the sidebar has no need to destroy itself manually. + oldWindow.tabs && + oldWindow.tabs.size == 0) { // not destroyed yet case + if (oldWindow.delayedDestroy) + clearTimeout(oldWindow.delayedDestroy); + oldWindow.delayedDestroy = setTimeout(() => { + // the last tab can be removed with browser.tabs.closeWindowWithLastTab=false, + // so we should not destroy the window immediately. + if (oldWindow.tabs && + oldWindow.tabs.size == 0) + oldWindow.destroy(); + }, (configs.collapseDuration, 1000) * 5); + } + + onCompleted(); + } + catch(e) { + console.log(e); + onCompleted(); + } +} + +async function onWindowCreated(win) { + const trackedWindow = TabsStore.windows.get(win.id) || new Window(win.id); + trackedWindow.incognito = win.incognito; +} + +async function onWindowRemoved(windowId) { + if (mPromisedStarted) + await mPromisedStarted; + + mTabsHighlightedTimers.delete(windowId); + mLastHighlightedCount.delete(windowId); + + const [onCompleted, previous] = addTabOperationQueue(); + if (!configs.acceleratedTabOperations && previous) + await previous; + + try { + log('onWindowRemoved ', windowId); + const win = TabsStore.windows.get(windowId); + if (win && + !TabsStore.getCurrentWindowId()) // skip destructor on sidebar + win.destroy(); + + onCompleted(); + } + catch(e) { + console.log(e); + onCompleted(); + } +} + + +browser.windows.onFocusChanged.addListener(windowId => { + mAppIsActive = windowId > 0; +}); + + +async function onGroupCreated(group) { + log('onGroupCreated ', group); + + const trackedGroup = TabGroup.init(group); + TabsStore.windows.get(trackedGroup.windowId).tabGroups.set(group.id, trackedGroup); + + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_GROUP_CREATED, + windowId: trackedGroup.windowId, + group: trackedGroup.$TST.sanitized, + }); +} + +async function onGroupUpdated(group) { + if (mPromisedStarted) + await mPromisedStarted; + + log('onGroupUpdated ', group); + + const trackedGroup = TabGroup.get(group.id); + trackedGroup.$TST.apply(group); + + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_GROUP_UPDATED, + windowId: trackedGroup.windowId, + group: trackedGroup.$TST.sanitized, + }); +} + +async function onGroupRemoved(group) { + if (mPromisedStarted) + await mPromisedStarted; + + log('onGroupRemoved ', group); + + const trackedGroup = TabGroup.get(group.id); + if (trackedGroup.windowId == group.windowId) { + trackedGroup.$TST.destroy(); + } + else { + log('onGroupRemoved: => moved to another window, no need to destroy'); + } + + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_GROUP_REMOVED, + windowId: group.windowId, + group, + }); +} + +async function onGroupMoved(group) { + if (mPromisedStarted) + await mPromisedStarted; + + log('onGroupMoved ', group); + const trackedGroup = TabGroup.get(group.id); + if (!trackedGroup) { + return; + } + + const oldWindowId = trackedGroup.windowId; + const newWindowId = group.windowId; + if (newWindowId == oldWindowId) { + return; + } + + const members = trackedGroup.$TST.members; + for (const tab of members) { + TabsStore.removeNativelyGroupedTab(tab, oldWindowId); + TabsStore.addNativelyGroupedTab(tab, newWindowId); + } + + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_GROUP_REMOVED, + windowId: oldWindowId, + group: trackedGroup.$TST.sanitized, + }); + + TabsStore.windows.get(oldWindowId).tabGroups.delete(group.id); + trackedGroup.windowId = newWindowId; + TabsStore.windows.get(newWindowId).tabGroups.set(group.id, trackedGroup); + + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_GROUP_CREATED, + windowId: newWindowId, + group: trackedGroup.$TST.sanitized, + }); +} diff --git a/waterfox/browser/components/sidebar/background/auto-sticky-tabs.js b/waterfox/browser/components/sidebar/background/auto-sticky-tabs.js new file mode 100644 index 000000000000..06463789c757 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/auto-sticky-tabs.js @@ -0,0 +1,66 @@ +/* +# 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'; + +import { + configs, +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; + +import { Tab } from '/common/TreeItem.js'; + +configs.$addObserver(key => { + switch (key) { + case 'stickyActiveTab': + updateAutoStickyActive(); + break; + + case 'stickySoundPlayingTab': + updateAutoStickySoundPlaying(); + break; + + case 'stickySharingTab': + updateAutoStickySharing(); + break; + + default: + break; + } +}); +configs.$loaded.then(() => { + updateAutoStickyActive(); + updateAutoStickySoundPlaying(); + updateAutoStickySharing(); +}); + +function updateAutoStickyActive() { + if (configs.stickyActiveTab) + Tab.registerAutoStickyState(Constants.kTAB_STATE_ACTIVE); + else + Tab.unregisterAutoStickyState(Constants.kTAB_STATE_ACTIVE); +} + +function updateAutoStickySoundPlaying() { + if (configs.stickySoundPlayingTab) + Tab.registerAutoStickyState(Constants.kTAB_STATE_SOUND_PLAYING); + else + Tab.unregisterAutoStickyState(Constants.kTAB_STATE_SOUND_PLAYING); +} + +function updateAutoStickySharing() { + if (configs.stickySharingTab) + Tab.registerAutoStickyState([ + Constants.kTAB_STATE_SHARING_CAMERA, + Constants.kTAB_STATE_SHARING_MICROPHONE, + Constants.kTAB_STATE_SHARING_SCREEN, + ]); + else + Tab.unregisterAutoStickyState([ + Constants.kTAB_STATE_SHARING_CAMERA, + Constants.kTAB_STATE_SHARING_MICROPHONE, + Constants.kTAB_STATE_SHARING_SCREEN, + ]); +} diff --git a/waterfox/browser/components/sidebar/background/background-cache.js b/waterfox/browser/components/sidebar/background/background-cache.js new file mode 100644 index 000000000000..cafbe55e3476 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/background-cache.js @@ -0,0 +1,614 @@ +/* +# 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'; + +import { SequenceMatcher } from '/extlib/diff.js'; + +import { + log as internalLogger, + dumpTab, + wait, + mapAndFilter, + configs +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as CacheStorage from '/common/cache-storage.js'; +import * as Constants from '/common/constants.js'; +import * as TabsInternalOperation from '/common/tabs-internal-operation.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TabsUpdate from '/common/tabs-update.js'; +import * as UniqueId from '/common/unique-id.js'; + +import MetricsData from '/common/MetricsData.js'; +import { Tab } from '/common/TreeItem.js'; + +import * as Tree from './tree.js'; + +function log(...args) { + internalLogger('background/background-cache', ...args); +} + +const kCONTENTS_VERSION = 5; + +let mActivated = false; +const mCaches = {}; + +export function activate() { + mActivated = true; + configs.$addObserver(onConfigChange); + + if (!configs.persistCachedTree) { + // clear obsolete cache + browser.windows.getAll().then(windows => { + for (const win of windows) { + browser.sessions.removeWindowValue(win.id, Constants.kWINDOW_STATE_CACHED_TABS).catch(ApiTabs.createErrorSuppressor()); + browser.sessions.removeWindowValue(win.id, Constants.kWINDOW_STATE_CACHED_SIDEBAR_TABS_DIRTY).catch(ApiTabs.createErrorSuppressor()); + } + }); + } +} + + +// =================================================================== +// restoring tabs from cache +// =================================================================== + +export async function restoreWindowFromEffectiveWindowCache(windowId, options = {}) { + MetricsData.add('restoreWindowFromEffectiveWindowCache: start'); + log(`restoreWindowFromEffectiveWindowCache for ${windowId} start`); + const owner = options.owner || getWindowCacheOwner(windowId); + if (!owner) { + log(`restoreWindowFromEffectiveWindowCache for ${windowId} fail: no owner`); + return false; + } + cancelReservedCacheTree(windowId); // prevent to break cache before loading + const tabs = options.tabs || await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler()); + if (configs.debug) + log(`restoreWindowFromEffectiveWindowCache for ${windowId} tabs: `, () => tabs.map(dumpTab)); + const actualSignature = getWindowSignature(tabs); + let cache = options.caches?.get(`window-${owner.windowId}`) || await MetricsData.addAsync('restoreWindowFromEffectiveWindowCache: window cache', getWindowCache(owner, Constants.kWINDOW_STATE_CACHED_TABS)); + if (!cache) { + log(`restoreWindowFromEffectiveWindowCache for ${windowId} fail: no cache`); + return false; + } + const promisedPermanentStates = Promise.all(tabs.map(tab => Tab.get(tab.id).$TST.getPermanentStates())); // don't await at here for better performance + MetricsData.add('restoreWindowFromEffectiveWindowCache: validity check: start'); + let cachedSignature = cache?.signature; + log(`restoreWindowFromEffectiveWindowCache for ${windowId}: got from the owner `, { + owner, cachedSignature, cache + }); + const signatureGeneratedFromCache = getWindowSignature(cache.tabs).join('\n'); + if (cache && + cache.tabs && + cachedSignature && + cachedSignature.join('\n') != signatureGeneratedFromCache) { + log(`restoreWindowFromEffectiveWindowCache for ${windowId}: cache is broken.`, { + cachedSignature: cachedSignature.join('\n'), + signatureGeneratedFromCache + }); + cache = cachedSignature = null; + TabsInternalOperation.clearCache(owner); + MetricsData.add('restoreWindowFromEffectiveWindowCache: validity check: signature failed.'); + } + else { + MetricsData.add('restoreWindowFromEffectiveWindowCache: validity check: signature passed.'); + } + if (options.ignorePinnedTabs && + cache && + cache.tabs && + cachedSignature) { + cache.tabs = trimTabsCache(cache.tabs, cache.pinnedTabsCount); + cachedSignature = trimSignature(cachedSignature, cache.pinnedTabsCount); + } + MetricsData.add('restoreWindowFromEffectiveWindowCache: validity check: matching actual signature of got cache'); + const signatureMatchResult = matcheSignatures({ + actual: actualSignature, + cached: cachedSignature + }); + log(`restoreWindowFromEffectiveWindowCache for ${windowId}: verify cache`, { + cache, actualSignature, cachedSignature, + ...signatureMatchResult, + }); + if (!cache || + cache.version != kCONTENTS_VERSION || + !signatureMatchResult.matched) { + log(`restoreWindowFromEffectiveWindowCache for ${windowId}: no effective cache`); + TabsInternalOperation.clearCache(owner); + MetricsData.add('restoreWindowFromEffectiveWindowCache: validity check: actual signature failed.'); + return false; + } + MetricsData.add('restoreWindowFromEffectiveWindowCache: validity check: actual signature passed.'); + cache.offset = signatureMatchResult.offset; + + log(`restoreWindowFromEffectiveWindowCache for ${windowId}: restore from cache`); + + const permanentStates = await MetricsData.addAsync('restoreWindowFromEffectiveWindowCache: permanentStatus', promisedPermanentStates); // await at here for better performance + const restored = await MetricsData.addAsync('restoreWindowFromEffectiveWindowCache: restoreTabsFromCache', restoreTabsFromCache(windowId, { cache, tabs, permanentStates })); + if (restored) { + MetricsData.add(`restoreWindowFromEffectiveWindowCache: window ${windowId} succeeded`); + // Now we reload the sidebar if it is opened, because it is the easiest way + // to synchronize state of tabs completely. + log('reload sidebar for a tree restored from cache'); + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_RELOAD, + windowId, + }).catch(ApiTabs.createErrorSuppressor()); + } + else { + MetricsData.add(`restoreWindowFromEffectiveWindowCache: window ${windowId} failed`); + } + + log(`restoreWindowFromEffectiveWindowCache for ${windowId}: restored = ${restored}`); + return restored; +} + +function getWindowSignature(tabs) { + return tabs.map(tab => `${tab.cookieStoreId},${tab.incognito},${tab.pinned},${tab.url}`); +} + +function trimSignature(signature, ignoreCount) { + if (!ignoreCount || ignoreCount < 0) + return signature; + return signature.slice(ignoreCount); +} + +function trimTabsCache(cache, ignoreCount) { + if (!ignoreCount || ignoreCount < 0) + return cache; + return cache.slice(ignoreCount); +} + +function matcheSignatures(signatures) { + const operations = (new SequenceMatcher(signatures.cached, signatures.actual)).operations(); + log('matcheSignatures: operations ', operations); + let matched = false; + let offset = 0; + for (const operation of operations) { + const [tag, fromStart, fromEnd, toStart, toEnd] = operation; + if (tag == 'equal' && + fromEnd - fromStart == signatures.cached.length) { + matched = true; + break; + } + offset += toEnd - toStart; + } + log('matcheSignatures: ', { matched, offset }); + return { matched, offset }; +} + + +async function restoreTabsFromCache(windowId, params = {}) { + if (!params.cache || + params.cache.version != kCONTENTS_VERSION) + return false; + + return (await restoreTabsFromCacheInternal({ + windowId, + tabs: params.tabs, + permanentStates: params.permanentStates, + offset: params.cache.offset || 0, + cache: params.cache.tabs + })).length > 0; +} + +async function restoreTabsFromCacheInternal(params) { + MetricsData.add('restoreTabsFromCacheInternal: start'); + log(`restoreTabsFromCacheInternal: restore tabs for ${params.windowId} from cache`); + const offset = params.offset || 0; + const win = TabsStore.windows.get(params.windowId); + const tabs = params.tabs.slice(offset).map(tab => Tab.get(tab.id)); + if (offset > 0 && + tabs.length <= offset) { + log('restoreTabsFromCacheInternal: missing window'); + return []; + } + log(`restoreTabsFromCacheInternal: there is ${win.tabs.size} tabs`); + if (params.cache.length != tabs.length) { + log('restoreTabsFromCacheInternal: Mismatched number of restored tabs?'); + return []; + } + try { + await MetricsData.addAsync('rebuildAll: fixupTabsRestoredFromCache', fixupTabsRestoredFromCache(tabs, params.permanentStates, params.cache)); + } + catch(e) { + log(String(e), e.stack); + throw e; + } + log('restoreTabsFromCacheInternal: done'); + if (configs.debug) + Tab.dumpAll(); + return tabs; +} + +async function fixupTabsRestoredFromCache(tabs, permanentStates, cachedTabs) { + MetricsData.add('fixupTabsRestoredFromCache: start'); + if (tabs.length != cachedTabs.length) + throw new Error(`fixupTabsRestoredFromCache: Mismatched number of tabs restored from cache, tabs=${tabs.length}, cachedTabs=${cachedTabs.length}`); + log('fixupTabsRestoredFromCache start ', () => ({ tabs: tabs.map(dumpTab), cachedTabs })); + const idMap = new Map(); + let remappedCount = 0; + // step 1: build a map from old id to new id + tabs = tabs.map((tab, index) => { + const cachedTab = cachedTabs[index]; + const oldId = cachedTab.id; + tab = Tab.get(tab.id); + log(`fixupTabsRestoredFromCache: remap ${oldId} => ${tab.id}`); + idMap.set(oldId, tab); + if (oldId != tab.id) + remappedCount++; + return tab; + }); + if (remappedCount && remappedCount < tabs.length) + throw new Error(`fixupTabsRestoredFromCache: not a window restoration, only ${remappedCount} tab(s) are restored (maybe restoration of closed tabs)`); + MetricsData.add('fixupTabsRestoredFromCache: step 1 done.'); + // step 2: restore information of tabs + // Do this from bottom to top, to reduce post operations for modified trees. + // (Attaching a tab to an existing tree will trigger "update" task for + // existing ancestors, but attaching existing subtree to a solo tab won't + // trigger such tasks.) + // See also: https://github.com/piroor/treestyletab/issues/2278#issuecomment-519387792 + for (let i = tabs.length - 1; i > -1; i--) { + fixupTabRestoredFromCache(tabs[i], permanentStates[i], cachedTabs[i], idMap); + } + // step 3: restore collapsed/expanded state of tabs and finalize the + // restoration process + // Do this from top to bottom, because a tab can be placed under an + // expanded parent but the parent can be placed under a collapsed parent. + for (const tab of tabs) { + fixupTabRestoredFromCachePostProcess(tab); + } + MetricsData.add('fixupTabsRestoredFromCache: step 2 done.'); +} + +function fixupTabRestoredFromCache(tab, permanentStates, cachedTab, idMap) { + tab.$TST.clear(); + const tabStates = new Set([...cachedTab.$TST.states, ...permanentStates]); + for (const state of Constants.kTAB_TEMPORARY_STATES) { + tabStates.delete(state); + } + tab.$TST.states = tabStates; + tab.$TST.attributes = cachedTab.$TST.attributes; + + log('fixupTabRestoredFromCache children: ', cachedTab.$TST.childIds); + const childIds = mapAndFilter(cachedTab.$TST.childIds, oldId => { + const tab = idMap.get(oldId); + return tab?.id || undefined; + }); + tab.$TST.children = childIds; + if (childIds.length > 0) + tab.$TST.setAttribute(Constants.kCHILDREN, `|${childIds.join('|')}|`); + else + tab.$TST.removeAttribute(Constants.kCHILDREN); + log('fixupTabRestoredFromCache children: => ', tab.$TST.childIds); + + log('fixupTabRestoredFromCache parent: ', cachedTab.$TST.parentId); + const parentTab = idMap.get(cachedTab.$TST.parentId) || null; + tab.$TST.parent = parentTab; + if (parentTab) + tab.$TST.setAttribute(Constants.kPARENT, parentTab.id); + else + tab.$TST.removeAttribute(Constants.kPARENT); + log('fixupTabRestoredFromCache parent: => ', tab.$TST.parentId); + + if (tab.discarded) { + tab.$TST.addState(Constants.kTAB_STATE_PENDING); + } + + tab.$TST.temporaryMetadata.set('treeStructureAlreadyRestoredFromSessionData', true); +} + +function fixupTabRestoredFromCachePostProcess(tab) { + const parentTab = tab.$TST.parent; + if (parentTab && + (parentTab.$TST.collapsed || + parentTab.$TST.subtreeCollapsed)) { + tab.$TST.addState(Constants.kTAB_STATE_COLLAPSED); + tab.$TST.addState(Constants.kTAB_STATE_COLLAPSED_DONE); + } + else { + tab.$TST.removeState(Constants.kTAB_STATE_COLLAPSED); + tab.$TST.removeState(Constants.kTAB_STATE_COLLAPSED_DONE); + } + + TabsStore.updateIndexesForTab(tab); + TabsUpdate.updateTab(tab, tab, { forceApply: true, onlyApply: true }); +} + + +// =================================================================== +// updating cache +// =================================================================== + +async function updateWindowCache(owner, key, value) { + if (!owner) + return; + + if (configs.persistCachedTree) { + try { + if (value) + await CacheStorage.setValue({ + windowId: owner.windowId, + key, + value, + store: CacheStorage.BACKGROUND, + }); + else + await CacheStorage.deleteValue({ + windowId: owner.windowId, + key, + store: CacheStorage.BACKGROUND, + }); + return; + } + catch(error) { + console.log(`BackgroundCache.updateWindowCache for ${owner.windowId}/${key} failed: `, error.message, error.stack, error); + } + } + + const storageKey = `backgroundCache-${await UniqueId.ensureWindowId(owner.windowId)}-${key}`; + if (value) + mCaches[storageKey] = value; + else + delete mCaches[storageKey]; +} + +export function markWindowCacheDirtyFromTab(tab, akey) { + const win = TabsStore.windows.get(tab.windowId); + if (!win) // the window may be closed + return; + if (win.markWindowCacheDirtyFromTabTimeout) + clearTimeout(win.markWindowCacheDirtyFromTabTimeout); + win.markWindowCacheDirtyFromTabTimeout = setTimeout(() => { + win.markWindowCacheDirtyFromTabTimeout = null; + updateWindowCache(win.lastWindowCacheOwner, akey, true); + }, 100); +} + +async function getWindowCache(owner, key) { + if (configs.persistCachedTree) { + try { + const value = await CacheStorage.getValue({ + windowId: owner.windowId, + key, + store: CacheStorage.BACKGROUND, + }); + return value; + } + catch(error) { + console.log(`BackgroundCache.getWindowCache for ${owner.windowId}/${key} failed: `, error.message, error.stack, error); + } + } + + const storageKey = `backgroundCache-${await UniqueId.ensureWindowId(owner.windowId)}-${key}`; + return mCaches[storageKey]; +} + +function getWindowCacheOwner(windowId) { + const tab = Tab.getFirstTab(windowId); + if (!tab) + return null; + return { + id: tab.id, + windowId: tab.windowId + }; +} + +export async function reserveToCacheTree(windowId, trigger) { + if (!mActivated || + !configs.useCachedTree) + return; + + const win = TabsStore.windows.get(windowId); + if (!win) + return; + + // If there is any opening (but not resolved its unique id yet) tab, + // we are possibly restoring tabs. To avoid cache breakage before + // restoration, we must wait until we know whether there is any other + // restoring tab or not. + if (Tab.needToWaitTracked(windowId)) + await Tab.waitUntilTrackedAll(windowId); + + if (win.promisedAllTabsRestored) // not restored yet + return; + + if (!trigger && configs.debug) + trigger = new Error().stack; + + log('reserveToCacheTree for window ', windowId, trigger); + TabsInternalOperation.clearCache(win.lastWindowCacheOwner); + + if (trigger) + reserveToCacheTree.triggers.add(trigger); + + if (win.waitingToCacheTree) + clearTimeout(win.waitingToCacheTree); + win.waitingToCacheTree = setTimeout(() => { + const triggers = [...reserveToCacheTree.triggers]; + reserveToCacheTree.triggers.clear(); + cacheTree(windowId, triggers); + }, 500); +} +reserveToCacheTree.triggers = new Set(); + +function cancelReservedCacheTree(windowId) { + const win = TabsStore.windows.get(windowId); + if (win?.waitingToCacheTree) { + clearTimeout(win.waitingToCacheTree); + delete win.waitingToCacheTree; + } +} + +async function cacheTree(windowId, triggers) { + if (Tab.needToWaitTracked(windowId)) + await Tab.waitUntilTrackedAll(windowId); + const win = TabsStore.windows.get(windowId); + if (!win || + !configs.useCachedTree) + return; + const signature = getWindowSignature(Tab.getAllTabs(windowId)); + if (win.promisedAllTabsRestored) // not restored yet + return; + //log('save cache for ', windowId); + win.lastWindowCacheOwner = getWindowCacheOwner(windowId); + if (!win.lastWindowCacheOwner) + return; + const firstTab = Tab.getFirstTab(windowId); + if (firstTab.incognito) { // never save cache for incognito windows + updateWindowCache(win.lastWindowCacheOwner, Constants.kWINDOW_STATE_CACHED_TABS, null); + return; + } + log('cacheTree for window ', windowId, triggers/*{ stack: configs.debug && new Error().stack }*/); + updateWindowCache(win.lastWindowCacheOwner, Constants.kWINDOW_STATE_CACHED_TABS, { + version: kCONTENTS_VERSION, + tabs: TabsStore.windows.get(windowId).export(true).tabs, + pinnedTabsCount: Tab.getPinnedTabs(windowId).length, + signature + }); +} + + +// update cache on events + +Tab.onCreated.addListener((tab, _info = {}) => { + if (!tab.$TST.previousTab) { // it is a new cache owner + const win = TabsStore.windows.get(tab.windowId); + if (win.lastWindowCacheOwner) + TabsInternalOperation.clearCache(win.lastWindowCacheOwner); + } + reserveToCacheTree(tab.windowId, 'tab created'); +}); + +// Tree restoration for "Restore Previous Session" +Tab.onWindowRestoring.addListener(async ({ windowId, restoredCount }) => { + if (!configs.useCachedTree) + return; + + log('Tabs.onWindowRestoring ', { windowId, restoredCount }); + if (restoredCount == 1) { + log('Tabs.onWindowRestoring: single tab restored'); + return; + } + + log('Tabs.onWindowRestoring: continue ', windowId); + MetricsData.add('Tabs.onWindowRestoring restore start'); + + const tabs = await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler()); + try { + await restoreWindowFromEffectiveWindowCache(windowId, { + ignorePinnedTabs: true, + owner: tabs[tabs.length - 1], + tabs + }); + MetricsData.add('Tabs.onWindowRestoring restore end'); + } + catch(e) { + log('Tabs.onWindowRestoring: FATAL ERROR while restoring tree from cache', String(e), e.stack); + } +}); + +Tab.onRemoved.addListener((tab, info) => { + if (!tab.$TST.previousTab) // the tab was the cache owner + TabsInternalOperation.clearCache(tab); + wait(0).then(() => { + // "Restore Previous Session" closes some tabs at first, so we should not clear the old cache yet. + // See also: https://dxr.mozilla.org/mozilla-central/rev/5be384bcf00191f97d32b4ac3ecd1b85ec7b18e1/browser/components/sessionstore/SessionStore.jsm#3053 + reserveToCacheTree(info.windowId, 'tab removed'); + }); +}); + +Tab.onMoved.addListener((tab, info) => { + if (info.fromIndex == 0) // the tab is not the cache owner anymore + TabsInternalOperation.clearCache(tab); + reserveToCacheTree(info.windowId, 'tab moved'); +}); + +Tab.onUpdated.addListener((tab, info) => { + markWindowCacheDirtyFromTab(tab, Constants.kWINDOW_STATE_CACHED_SIDEBAR_TABS_DIRTY); + if ('url' in info) + reserveToCacheTree(tab.windowId, 'tab updated'); +}); + +Tab.onStateChanged.addListener((tab, state, _has) => { + if (state == Constants.kTAB_STATE_STICKY) + markWindowCacheDirtyFromTab(tab, Constants.kWINDOW_STATE_CACHED_SIDEBAR_TABS_DIRTY); +}); + +Tree.onSubtreeCollapsedStateChanging.addListener(tab => { + reserveToCacheTree(tab.windowId, 'subtree collapsed/expanded'); +}); + +Tree.onAttached.addListener((tab, _info) => { + wait(0).then(() => { + // "Restore Previous Session" closes some tabs at first and it causes tree changes, so we should not clear the old cache yet. + // See also: https://dxr.mozilla.org/mozilla-central/rev/5be384bcf00191f97d32b4ac3ecd1b85ec7b18e1/browser/components/sessionstore/SessionStore.jsm#3053 + reserveToCacheTree(tab.windowId, 'tab attached to tree'); + }); +}); + +Tree.onDetached.addListener((tab, _info) => { + TabsInternalOperation.clearCache(tab); + wait(0).then(() => { + // "Restore Previous Session" closes some tabs at first and it causes tree changes, so we should not clear the old cache yet. + // See also: https://dxr.mozilla.org/mozilla-central/rev/5be384bcf00191f97d32b4ac3ecd1b85ec7b18e1/browser/components/sessionstore/SessionStore.jsm#3053 + reserveToCacheTree(tab.windowId, 'tab detached from tree'); + }); +}); + +Tab.onPinned.addListener(tab => { + reserveToCacheTree(tab.windowId, 'tab pinned'); +}); + +Tab.onUnpinned.addListener(tab => { + if (tab.$TST.previousTab) // the tab was the cache owner + TabsInternalOperation.clearCache(tab); + reserveToCacheTree(tab.windowId, 'tab unpinned'); +}); + +Tab.onShown.addListener(tab => { + reserveToCacheTree(tab.windowId, 'tab shown'); +}); + +Tab.onHidden.addListener(tab => { + reserveToCacheTree(tab.windowId, 'tab hidden'); +}); + +browser.windows.onRemoved.addListener(async windowId => { + try { + CacheStorage.clearForWindow(windowId); + } + catch(_error) { + } + + const storageKeyPart = `Cache-${await UniqueId.ensureWindowId(windowId)}-`; + for (const key in mCaches) { + if (key.includes(storageKeyPart)) + delete mCaches[key]; + } +}); + +function onConfigChange(key) { + switch (key) { + case 'useCachedTree': + case 'persistCachedTree': + browser.windows.getAll({ + populate: true, + windowTypes: ['normal'] + }).then(windows => { + for (const win of windows) { + const owner = win.tabs[win.tabs.length - 1]; + if (configs[key]) { + reserveToCacheTree(win.id, 'config change'); + } + else { + TabsInternalOperation.clearCache(owner); + location.reload(); + } + } + }).catch(ApiTabs.createErrorSuppressor()); + break; + } +} diff --git a/waterfox/browser/components/sidebar/background/background.html b/waterfox/browser/components/sidebar/background/background.html new file mode 100644 index 000000000000..36585f26eb12 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/background.html @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/waterfox/browser/components/sidebar/background/background.js b/waterfox/browser/components/sidebar/background/background.js new file mode 100644 index 000000000000..d2efe29e4b4e --- /dev/null +++ b/waterfox/browser/components/sidebar/background/background.js @@ -0,0 +1,928 @@ +/* +# 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'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; + +import { + log as internalLogger, + wait, + configs, + sanitizeForHTMLText, + waitUntilStartupOperationsUnblocked, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from '/common/constants.js'; +import * as ContextualIdentities from '/common/contextual-identities.js'; +import * as Dialog from '/common/dialog.js'; +import * as Permissions from '/common/permissions.js'; +import * as SidebarConnection from '/common/sidebar-connection.js'; +import * as Sync from '/common/sync.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TabsUpdate from '/common/tabs-update.js'; +import * as TSTAPI from '/common/tst-api.js'; +import * as UniqueId from '/common/unique-id.js'; +import '/common/bookmark.js'; // we need to load this once in the background page to register the global listener + +import MetricsData from '/common/MetricsData.js'; +import { Tab, TabGroup } from '/common/TreeItem.js'; +import Window from '/common/Window.js'; + +import * as ApiTabsListener from './api-tabs-listener.js'; +import * as BackgroundCache from './background-cache.js'; +import * as Commands from './commands.js'; +import * as ContextMenu from './context-menu.js'; +import * as Migration from './migration.js'; +import * as NativeTabGroups from './native-tab-groups.js'; +import * as TabContextMenu from './tab-context-menu.js'; +import * as Tree from './tree.js'; +import * as TreeStructure from './tree-structure.js'; +import './browser-action-menu.js'; +import './duplicated-tab-detection.js'; +import './successor-tab.js'; + +function log(...args) { + internalLogger('background/background', ...args); +} + +// This needs to be large enough for bulk updates on multiple tabs. +const DELAY_TO_PROCESS_RESERVED_UPDATE_TASKS = 250; + +export const onInit = new EventListenerManager(); +export const onBuilt = new EventListenerManager(); +export const onReady = new EventListenerManager(); +export const onDestroy = new EventListenerManager(); +export const onTreeCompletelyAttached = new EventListenerManager(); + +export const instanceId = `${Date.now()}-${parseInt(Math.random() * 65000)}`; + +const mDarkModeMatchMedia = window.matchMedia('(prefers-color-scheme: dark)'); + +let mInitialized = false; +const mPreloadedCaches = new Map(); + +async function getAllWindows() { + const [windows, tabGroups] = await Promise.all([ + browser.windows.getAll({ + populate: true, + // We need to track all type windows because + // popup windows can be destination of tabs.move(). + // See also: https://github.com/piroor/treestyletab/issues/3311 + windowTypes: ['normal', 'panel', 'popup'], + }).catch(ApiTabs.createErrorHandler()), + browser.tabGroups.query({}), + ]); + + const groupsByWindow = new Map(); + for (const group of tabGroups) { + const groupsInWindow = groupsByWindow.get(group.windowId) || []; + groupsInWindow.push(group); + groupsByWindow.set(group.windowId, groupsInWindow) + } + + for (const win of windows) { + win.tabGroups = groupsByWindow.get(win.id) || []; + } + + return windows; +} + +log('init: Start queuing of messages notified via WE APIs'); +ApiTabsListener.init(); +const promisedRestored = waitUntilCompletelyRestored(); // this must be called synchronosly + +export async function init() { + log('init: start'); + MetricsData.add('init: start'); + window.addEventListener('pagehide', destroy, { once: true }); + + onInit.dispatch(); + SidebarConnection.init(); + + // Read caches from existing tabs at first, for better performance. + // Those promises will be resolved while waiting for waitUntilCompletelyRestored(). + getAllWindows() + .then(windows => { + for (const win of windows) { + browser.sessions.getWindowValue(win.id, Constants.kWINDOW_STATE_CACHED_TABS) + .catch(ApiTabs.createErrorSuppressor()) + .then(cache => mPreloadedCaches.set(`window-${win.id}`, cache)); + const tab = win.tabs[0]; + browser.sessions.getTabValue(tab.id, Constants.kWINDOW_STATE_CACHED_TABS) + .catch(ApiTabs.createErrorSuppressor()) + .then(cache => mPreloadedCaches.set(`tab-${tab.id}`, cache)); + } + }); + + let promisedWindows; + log('init: Getting existing windows and tabs'); + await MetricsData.addAsync('init: waiting for waitUntilCompletelyRestored, ContextualIdentities.init and configs.$loaded', Promise.all([ + promisedRestored.then(() => { + // don't wait at here for better performance + promisedWindows = getAllWindows(); + }), + ContextualIdentities.init(), + configs.$loaded.then(waitUntilStartupOperationsUnblocked), + ])); + MetricsData.add('init: prepare'); + EventListenerManager.debug = configs.debug; + + Migration.migrateConfigs(); + Migration.migrateBookmarkUrls(); + configs.grantedRemovingTabIds = []; // clear! + MetricsData.add('init: Migration.migrateConfigs'); + + updatePanelUrl(); + + const windows = await MetricsData.addAsync('init: getting all tabs across windows', promisedWindows); // wait at here for better performance + const restoredFromCache = await MetricsData.addAsync('init: rebuildAll', rebuildAll(windows)); + mPreloadedCaches.clear(); + await MetricsData.addAsync('init: TreeStructure.loadTreeStructure', TreeStructure.loadTreeStructure(windows, restoredFromCache)); + + log('init: Start to process messages including queued ones'); + ApiTabsListener.start(); + + Migration.tryNotifyNewFeatures(); + + ContextualIdentities.startObserve(); + onBuilt.dispatch(); // after this line, this master process may receive "kCOMMAND_PING_TO_BACKGROUND" requests from sidebars. + MetricsData.add('init: started listening'); + + TabContextMenu.init(); + ContextMenu.init().then(() => updateIconForBrowserTheme()); + MetricsData.add('init: started initializing of context menu'); + + Permissions.clearRequest(); + + for (const windowId of restoredFromCache.keys()) { + if (!restoredFromCache[windowId]) + BackgroundCache.reserveToCacheTree(windowId, 'initialize'); + TabsUpdate.completeLoadingTabs(windowId); + } + + for (const tab of Tab.getAllTabs(null, { iterator: true })) { + updateSubtreeCollapsed(tab); + } + for (const tab of Tab.getActiveTabs()) { + for (const ancestor of tab.$TST.ancestors) { + Tree.collapseExpandTabAndSubtree(ancestor, { + collapsed: false, + justNow: true + }); + } + } + + // we don't need to await that for the initialization of TST itself. + MetricsData.addAsync('init: initializing API for other addons', TSTAPI.initAsBackend()); + + mInitialized = true; + UniqueId.readyToDetectDuplicatedTab(); + Tab.broadcastState.enabled = true; + onReady.dispatch(); + BackgroundCache.activate(); + TreeStructure.startTracking(); + + Sync.init(); + + await NativeTabGroups.startToMaintainTree(); + + await MetricsData.addAsync('init: exporting tabs to sidebars', notifyReadyToSidebars()); + + log(`Startup metrics for ${TabsStore.tabs.size} tabs: `, MetricsData.toString()); +} + +async function notifyReadyToSidebars() { + log('notifyReadyToSidebars: start'); + const promisedResults = []; + for (const win of TabsStore.windows.values()) { + // Send PING to all windows whether they are detected as opened or not, because + // the connection may be established before this background page starts listening + // of messages from sidebar pages. + // See also: https://github.com/piroor/treestyletab/issues/2200 + TabsUpdate.completeLoadingTabs(win.id); // failsafe + log(`notifyReadyToSidebars: to ${win.id}`); + promisedResults.push(browser.runtime.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_BACKGROUND_READY, + windowId: win.id, + exported: win.export(true), // send tabs together to optimizie further initialization tasks in the sidebar + }).catch(ApiTabs.createErrorSuppressor())); + } + return Promise.all(promisedResults); +} + +async function updatePanelUrl(theme) { + const url = new URL(Constants.kSHORTHAND_URIS.tabbar); + url.searchParams.set('style', configs.style); + url.searchParams.set('reloadMaskImage', !!configs.enableWorkaroundForBug1763420_reloadMaskImage); + if (!theme) + theme = await browser.theme.getCurrent(); + if (browser.sidebarAction) + browser.sidebarAction.setPanel({ panel: url.href }); +/* + const url = new URL(Constants.kSHORTHAND_URIS.tabbar); + url.searchParams.set('style', configs.style); + if (browser.sidebarAction) + browser.sidebarAction.setPanel({ panel: url.href }); +*/ +} + +async function waitUntilCompletelyRestored() { + log('waitUntilCompletelyRestored'); + const initialTabs = await browser.tabs.query({}); + await Promise.all([ + MetricsData.addAsync('waitUntilCompletelyRestored: existing tabs ', Promise.all( + initialTabs.map(tab => waitUntilPersistentIdBecomeAvailable(tab.id).catch(_error => {})) + )), + MetricsData.addAsync('waitUntilCompletelyRestored: opening tabs ', new Promise((resolve, _reject) => { + let promises = []; + let timeout; + let resolver; + let onNewTabRestored = async (tab, _info = {}) => { + clearTimeout(timeout); + log('new restored tab is detected.'); + promises.push(waitUntilPersistentIdBecomeAvailable(tab.id).catch(_error => {})); + // Read caches from restored tabs while waiting, for better performance. + browser.sessions.getWindowValue(tab.windowId, Constants.kWINDOW_STATE_CACHED_TABS) + .catch(ApiTabs.createErrorSuppressor()) + .then(cache => mPreloadedCaches.set(`window-${tab.windowId}`, cache)); + browser.sessions.getTabValue(tab.id, Constants.kWINDOW_STATE_CACHED_TABS) + .catch(ApiTabs.createErrorSuppressor()) + .then(cache => mPreloadedCaches.set(`tab-${tab.id}`, cache)); + //uniqueId = uniqueId?.id || '?'; // not used + timeout = setTimeout(resolver, 100); + }; + browser.tabs.onCreated.addListener(onNewTabRestored); + resolver = (async () => { + log(`timeout: all ${promises.length} tabs are restored. `, promises); + browser.tabs.onCreated.removeListener(onNewTabRestored); + timeout = resolver = onNewTabRestored = undefined; + await Promise.all(promises); + promises = undefined; + resolve(); + }); + timeout = setTimeout(resolver, 500); + })), + ]); +} +async function waitUntilPersistentIdBecomeAvailable(tabId, retryCount = 0) { + if (retryCount > 10) { + console.log(`could not get persistent ID for ${tabId}`); + return false; + } + const uniqueId = await browser.sessions.getTabValue(tabId, Constants.kPERSISTENT_ID); + if (!uniqueId) + return wait(100).then(() => waitUntilPersistentIdBecomeAvailable(tabId, retryCount + 1)); + return true; +} + +function destroy() { + browser.runtime.sendMessage({ + type: TSTAPI.kUNREGISTER_SELF + }).catch(ApiTabs.createErrorSuppressor()); + + // This API doesn't work as expected because it is not notified to + // other addons actually when browser.runtime.sendMessage() is called + // on pagehide or something unloading event. + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_SHUTDOWN + }).catch(ApiTabs.createErrorSuppressor()); + + onDestroy.dispatch(); + ApiTabsListener.destroy(); + ContextualIdentities.endObserve(); +} + +async function rebuildAll(windows) { + if (!windows) + windows = await getAllWindows(); + const restoredFromCache = new Map(); + await Promise.all(windows.map(async win => { + await MetricsData.addAsync(`rebuildAll: tabs in window ${win.id}`, async () => { + let trackedWindow = TabsStore.windows.get(win.id); + if (!trackedWindow) + trackedWindow = Window.init(win.id, win.tabGroups.map(TabGroup.init)); + + for (const tab of win.tabs) { + Tab.track(tab); + Tab.init(tab, { existing: true }); + tryStartHandleAccelKeyOnTab(tab); + } + try { + if (configs.useCachedTree) { + log(`trying to restore window ${win.id} from cache`); + const restored = await MetricsData.addAsync(`rebuildAll: restore tabs in window ${win.id} from cache`, BackgroundCache.restoreWindowFromEffectiveWindowCache(win.id, { + owner: win.tabs[win.tabs.length - 1], + tabs: win.tabs, + caches: mPreloadedCaches + })); + restoredFromCache.set(win.id, restored); + log(`window ${win.id}: restored from cache?: `, restored); + if (restored) + return; + } + } + catch(e) { + log(`failed to restore tabs for ${win.id} from cache `, e); + } + try { + log(`build tabs for ${win.id} from scratch`); + Window.init(win.id, win.tabGroups.map(TabGroup.init)); + const promises = []; + for (let tab of win.tabs) { + tab = Tab.get(tab.id); + tab.$TST.clear(); // clear dirty restored states + promises.push( + tab.$TST.getPermanentStates() + .then(states => { + tab.$TST.states = new Set(states); + tab.$TST.addState(Constants.kTAB_STATE_PENDING); + }) + .catch(console.error) + .then(() => { + TabsUpdate.updateTab(tab, tab, { forceApply: true }); + }) + ); + tryStartHandleAccelKeyOnTab(tab); + } + await Promise.all(promises); + } + catch(e) { + log(`failed to build tabs for ${win.id}`, e); + } + restoredFromCache.set(win.id, false); + }); + for (const tab of Tab.getGroupTabs(win.id, { iterator: true })) { + if (!tab.discarded) + tab.$TST.temporaryMetadata.set('shouldReloadOnSelect', true); + } + })); + return restoredFromCache; +} + +export async function reload(options = {}) { + mPreloadedCaches.clear(); + for (const win of TabsStore.windows.values()) { + win.clear(); + } + TabsStore.clear(); + const windows = await getAllWindows(); + await MetricsData.addAsync('reload: rebuildAll', rebuildAll(windows)); + await MetricsData.addAsync('reload: TreeStructure.loadTreeStructure', TreeStructure.loadTreeStructure(windows)); + if (!options.all) + return; + for (const win of TabsStore.windows.values()) { + if (!SidebarConnection.isOpen(win.id)) + continue; + log('reload all sidebars: ', new Error().stack); + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_RELOAD + }).catch(ApiTabs.createErrorSuppressor()); + } +} + +export async function tryStartHandleAccelKeyOnTab(tab) { + if (!TabsStore.ensureLivingItem(tab)) + return; + const granted = await Permissions.isGranted(Permissions.ALL_URLS); + if (!granted || + /^(about|chrome|resource):/.test(tab.url)) + return; + try { + //log(`tryStartHandleAccelKeyOnTab: initialize tab ${tab.id}`); + if (browser.scripting) // Manifest V3 + browser.scripting.executeScript({ + target: { + tabId: tab.id, + allFrames: true, + }, + files: ['/common/handle-accel-key.js'], + }).catch(ApiTabs.createErrorSuppressor(ApiTabs.handleMissingTabError, ApiTabs.handleMissingHostPermissionError)); + else + browser.tabs.executeScript(tab.id, { + file: '/common/handle-accel-key.js', + allFrames: true, + matchAboutBlank: true, + runAt: 'document_start' + }).catch(ApiTabs.createErrorSuppressor(ApiTabs.handleMissingTabError, ApiTabs.handleMissingHostPermissionError)); + } + catch(error) { + console.log(error); + } +} + +export function reserveToUpdateInsertionPosition(tabOrTabs) { + const tabs = Array.isArray(tabOrTabs) ? tabOrTabs : [tabOrTabs] ; + for (const tab of tabs) { + if (!TabsStore.ensureLivingItem(tab)) + continue; + const reserved = reserveToUpdateInsertionPosition.reserved.get(tab.windowId) || { + timer: null, + tabs: new Set() + }; + if (reserved.timer) + clearTimeout(reserved.timer); + reserved.tabs.add(tab); + reserved.timer = setTimeout(() => { + reserveToUpdateInsertionPosition.reserved.delete(tab.windowId); + for (const tab of reserved.tabs) { + if (!tab.$TST) + continue; + updateInsertionPosition(tab); + } + }, DELAY_TO_PROCESS_RESERVED_UPDATE_TASKS); + reserveToUpdateInsertionPosition.reserved.set(tab.windowId, reserved); + } +} +reserveToUpdateInsertionPosition.reserved = new Map(); + +async function updateInsertionPosition(tab) { + if (!TabsStore.ensureLivingItem(tab)) + return; + + const prev = tab.hidden ? tab.$TST.unsafePreviousTab : tab.$TST.previousTab; + if (prev) + browser.sessions.setTabValue( + tab.id, + Constants.kPERSISTENT_INSERT_AFTER, + prev.$TST.uniqueId.id + ).catch(ApiTabs.createErrorSuppressor( + ApiTabs.handleMissingTabError // The tab can be closed while waiting. + )); + else + browser.sessions.removeTabValue( + tab.id, + Constants.kPERSISTENT_INSERT_AFTER + ).catch(ApiTabs.createErrorSuppressor( + ApiTabs.handleMissingTabError // The tab can be closed while waiting. + )); + + // This code should be removed after legacy data are cleared enough, maybe after Firefox 128 is released. + browser.sessions.removeTabValue( + tab.id, + Constants.kPERSISTENT_INSERT_AFTER_LEGACY + ).catch(ApiTabs.createErrorSuppressor( + ApiTabs.handleMissingTabError // The tab can be closed while waiting. + )); + + const next = tab.hidden ? tab.$TST.unsafeNextTab : tab.$TST.nextTab; + if (next) + browser.sessions.setTabValue( + tab.id, + Constants.kPERSISTENT_INSERT_BEFORE, + next.$TST.uniqueId.id + ).catch(ApiTabs.createErrorSuppressor( + ApiTabs.handleMissingTabError // The tab can be closed while waiting. + )); + else + browser.sessions.removeTabValue( + tab.id, + Constants.kPERSISTENT_INSERT_BEFORE + ).catch(ApiTabs.createErrorSuppressor( + ApiTabs.handleMissingTabError // The tab can be closed while waiting. + )); +} + + +export function reserveToUpdateAncestors(tabOrTabs) { + const tabs = Array.isArray(tabOrTabs) ? tabOrTabs : [tabOrTabs] ; + for (const tab of tabs) { + if (!TabsStore.ensureLivingItem(tab)) + continue; + const reserved = reserveToUpdateAncestors.reserved.get(tab.windowId) || { + timer: null, + tabs: new Set() + }; + if (reserved.timer) + clearTimeout(reserved.timer); + reserved.tabs.add(tab); + reserved.timer = setTimeout(() => { + reserveToUpdateAncestors.reserved.delete(tab.windowId); + for (const tab of reserved.tabs) { + if (!tab.$TST) + continue; + updateAncestors(tab); + } + }, DELAY_TO_PROCESS_RESERVED_UPDATE_TASKS); + reserveToUpdateAncestors.reserved.set(tab.windowId, reserved); + } +} +reserveToUpdateAncestors.reserved = new Map(); + +async function updateAncestors(tab) { + if (!TabsStore.ensureLivingItem(tab)) + return; + + const ancestors = tab.$TST.ancestors.map(ancestor => ancestor.$TST.uniqueId.id); + log(`updateAncestors: save persistent ancestors for ${tab.id}: `, ancestors); + browser.sessions.setTabValue( + tab.id, + Constants.kPERSISTENT_ANCESTORS, + ancestors + ).catch(ApiTabs.createErrorSuppressor( + ApiTabs.handleMissingTabError // The tab can be closed while waiting. + )); +} + +export function reserveToUpdateChildren(tabOrTabs) { + const tabs = Array.isArray(tabOrTabs) ? tabOrTabs : [tabOrTabs] ; + for (const tab of tabs) { + if (!TabsStore.ensureLivingItem(tab)) + continue; + const reserved = reserveToUpdateChildren.reserved.get(tab.windowId) || { + timer: null, + tabs: new Set() + }; + if (reserved.timer) + clearTimeout(reserved.timer); + reserved.tabs.add(tab); + reserved.timer = setTimeout(() => { + reserveToUpdateChildren.reserved.delete(tab.windowId); + for (const tab of reserved.tabs) { + if (!tab.$TST) + continue; + updateChildren(tab); + } + }, DELAY_TO_PROCESS_RESERVED_UPDATE_TASKS); + reserveToUpdateChildren.reserved.set(tab.windowId, reserved); + } +} +reserveToUpdateChildren.reserved = new Map(); + +async function updateChildren(tab) { + if (!TabsStore.ensureLivingItem(tab)) + return; + + const children = tab.$TST.children.map(child => child.$TST.uniqueId.id); + log(`updateChildren: save persistent children for ${tab.id}: `, children); + browser.sessions.setTabValue( + tab.id, + Constants.kPERSISTENT_CHILDREN, + children + ).catch(ApiTabs.createErrorSuppressor( + ApiTabs.handleMissingTabError // The tab can be closed while waiting. + )); +} + +function reserveToUpdateSubtreeCollapsed(tab) { + if (!mInitialized || + !TabsStore.ensureLivingItem(tab)) + return; + const reserved = reserveToUpdateSubtreeCollapsed.reserved.get(tab.windowId) || { + timer: null, + tabs: new Set() + }; + if (reserved.timer) + clearTimeout(reserved.timer); + reserved.tabs.add(tab); + reserved.timer = setTimeout(() => { + reserveToUpdateSubtreeCollapsed.reserved.delete(tab.windowId); + for (const tab of reserved.tabs) { + if (!tab.$TST) + continue; + updateSubtreeCollapsed(tab); + } + }, DELAY_TO_PROCESS_RESERVED_UPDATE_TASKS); + reserveToUpdateSubtreeCollapsed.reserved.set(tab.windowId, reserved); +} +reserveToUpdateSubtreeCollapsed.reserved = new Map(); + +async function updateSubtreeCollapsed(tab) { + if (!TabsStore.ensureLivingItem(tab)) + return; + tab.$TST.toggleState(Constants.kTAB_STATE_SUBTREE_COLLAPSED, tab.$TST.subtreeCollapsed, { permanently: true }); +} + +export async function confirmToCloseTabs(tabs, { windowId, configKey, messageKey, titleKey, minConfirmCount } = {}) { + if (!windowId) + windowId = tabs[0].windowId; + + const grantedIds = new Set(configs.grantedRemovingTabIds); + let count = 0; + const tabIds = []; + tabs = tabs.map(tab => Tab.get(tab?.id)).filter(tab => { + if (tab && !grantedIds.has(tab.id)) { + count++; + tabIds.push(tab.id); + return true; + } + return false; + }); + if (!configKey) + configKey = 'warnOnCloseTabs'; + const shouldConfirm = configs[configKey]; + const deltaFromLastConfirmation = Date.now() - configs.lastConfirmedToCloseTabs; + log('confirmToCloseTabs ', { tabIds, count, windowId, configKey, grantedIds, shouldConfirm, deltaFromLastConfirmation, minConfirmCount }); + if (count <= (typeof minConfirmCount == 'number' ? minConfirmCount : 1) || + !shouldConfirm || + deltaFromLastConfirmation < 500) { + log('confirmToCloseTabs: skip confirmation and treated as granted'); + return true; + } + + const win = await browser.windows.get(windowId); + const listing = configs.warnOnCloseTabsWithListing ? + Dialog.tabsToHTMLList(tabs, { + maxHeight: Math.round(win.height * 0.8), + maxWidth: Math.round(win.width * 0.75) + }) : + ''; + + const result = await Dialog.show(win, { + content: ` +
${sanitizeForHTMLText(browser.i18n.getMessage(messageKey || 'warnOnCloseTabs_message', [count]))}
${listing} + `.trim(), + buttons: [ + browser.i18n.getMessage('warnOnCloseTabs_close'), + browser.i18n.getMessage('warnOnCloseTabs_cancel') + ], + checkMessage: browser.i18n.getMessage('warnOnCloseTabs_warnAgain'), + checked: true, + modal: !configs.debug, // for popup + type: 'common-dialog', // for popup + url: ((await Permissions.isGranted(Permissions.ALL_URLS)) ? null : '/resources/blank.html'), // for popup + title: browser.i18n.getMessage(titleKey || 'warnOnCloseTabs_title'), // for popup + onShownInPopup(container) { + setTimeout(() => { // because window.requestAnimationFrame is decelerate for an invisible document. + // this need to be done on the next tick, to use the height of + // the box for calculation of dialog size + const style = container.querySelector('ul').style; + style.height = '0px'; // this makes the box shrinkable + style.maxHeight = 'none'; + style.minHeight = '0px'; + }, 0); + } + }); + + log('confirmToCloseTabs: result = ', result); + switch (result.buttonIndex) { + case 0: + if (!result.checked) + configs[configKey] = false; + configs.grantedRemovingTabIds = Array.from(new Set((configs.grantedRemovingTabIds || []).concat(tabIds))); + log('confirmToCloseTabs: granted ', configs.grantedRemovingTabIds); + reserveToClearGrantedRemovingTabs(); + return true; + default: + return false; + } +} +Commands.onTabsClosing.addListener((tabIds, options = {}) => { + return confirmToCloseTabs(tabIds.map(Tab.get), options); +}); + +function reserveToClearGrantedRemovingTabs() { + const lastGranted = configs.grantedRemovingTabIds.join(','); + setTimeout(() => { + if (configs.grantedRemovingTabIds.join(',') == lastGranted) + configs.grantedRemovingTabIds = []; + }, 1000); +} + +Tab.onCreated.addListener((tab, info = {}) => { + if (!info.duplicated) + return; + // Duplicated tab has its own tree structure information inherited + // from the original tab, but they must be cleared. + reserveToUpdateAncestors(tab); + reserveToUpdateChildren(tab); + reserveToUpdateInsertionPosition([ + tab, + tab.hidden ? tab.$TST.unsafePreviousTab : tab.$TST.previousTab, + tab.hidden ? tab.$TST.unsafeNextTab : tab.$TST.nextTab + ]); +}); + +Tab.onUpdated.addListener((tab, changeInfo) => { + if (!mInitialized) + return; + + // Loading of "about:(unknown type)" won't report new URL via tabs.onUpdated, + // so we need to see the complete tab object. + const status = changeInfo.status || tab?.status; + const url = changeInfo.url ? changeInfo.url : + status == 'complete' && tab ? tab.url : ''; + if (tab && + Constants.kSHORTHAND_ABOUT_URI.test(url)) { + const shorthand = RegExp.$1; + const oldUrl = tab.url; + wait(100).then(() => { // redirect with delay to avoid infinite loop of recursive redirections. + if (tab.url != oldUrl) + return; + browser.tabs.update(tab.id, { + url: url.replace(Constants.kSHORTHAND_ABOUT_URI, Constants.kSHORTHAND_URIS[shorthand] || 'about:blank') + }).catch(ApiTabs.createErrorSuppressor(ApiTabs.handleMissingTabError)); + if (shorthand == 'group') + tab.$TST.addState(Constants.kTAB_STATE_GROUP_TAB, { permanently: true }); + }); + } + + if (changeInfo.status || changeInfo.url) + tryStartHandleAccelKeyOnTab(tab); +}); + +Tab.onShown.addListener(tab => { + if (!mInitialized) + return; + + if (configs.fixupTreeOnTabVisibilityChanged) { + reserveToUpdateAncestors(tab); + reserveToUpdateChildren(tab); + } + reserveToUpdateInsertionPosition([ + tab, + tab.hidden ? tab.$TST.unsafePreviousTab : tab.$TST.previousTab, + tab.hidden ? tab.$TST.unsafeNextTab : tab.$TST.nextTab + ]); +}); + +Tab.onMutedStateChanged.addListener((root, toBeMuted) => { + if (!mInitialized) + return; + + // Spread muted state of a parent tab to its collapsed descendants + if (!root.$TST.subtreeCollapsed || + // We don't need to spread muted state to descendants of multiselected + // tabs here, because tabs.update() was called with all multiselected tabs. + root.$TST.multiselected || + // We should not spread muted state to descendants of collapsed tab + // recursively, because they were already controlled from a visible + // ancestor. + root.$TST.collapsed) + return; + + const tabs = root.$TST.descendants; + for (const tab of tabs) { + const playing = tab.$TST.soundPlaying; + const muted = tab.$TST.muted; + log(`tab ${tab.id}: playing=${playing}, muted=${muted}`); + if (configs.spreadMutedStateOnlyToSoundPlayingTabs && + !playing && + playing != toBeMuted) + continue; + + log(` => set muted=${toBeMuted}`); + browser.tabs.update(tab.id, { + muted: toBeMuted + }).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + + const add = []; + const remove = []; + if (toBeMuted) { + add.push(Constants.kTAB_STATE_MUTED); + tab.$TST.addState(Constants.kTAB_STATE_MUTED); + } + else { + remove.push(Constants.kTAB_STATE_MUTED); + tab.$TST.removeState(Constants.kTAB_STATE_MUTED); + } + + if (tab.audible && !toBeMuted) { + add.push(Constants.kTAB_STATE_SOUND_PLAYING); + tab.$TST.addState(Constants.kTAB_STATE_SOUND_PLAYING); + } + else { + remove.push(Constants.kTAB_STATE_SOUND_PLAYING); + tab.$TST.removeState(Constants.kTAB_STATE_SOUND_PLAYING); + } + + // tabs.onUpdated is too slow, so users will be confused + // from still-not-updated tabs (in other words, they tabs + // are unresponsive for quick-clicks). + Tab.broadcastState(tab, { add, remove }); + } +}); + +Tab.onTabInternallyMoved.addListener((tab, info = {}) => { + reserveToUpdateInsertionPosition([ + tab, + tab.hidden ? tab.$TST.unsafePreviousTab : tab.$TST.previousTab, + tab.hidden ? tab.$TST.unsafeNextTab : tab.$TST.nextTab, + info.oldPreviousTab, + info.oldNextTab + ]); +}); + +Tab.onMoved.addListener((tab, moveInfo) => { + if (moveInfo.movedInBulk) + return; + reserveToUpdateInsertionPosition([ + tab, + moveInfo.oldPreviousTab, + moveInfo.oldNextTab, + tab.hidden ? tab.$TST.unsafePreviousTab : tab.$TST.previousTab, + tab.hidden ? tab.$TST.unsafeNextTab : tab.$TST.nextTab + ]); +}); + +Tree.onAttached.addListener(async (tab, attachInfo) => { + await tab.$TST.opened; + + if (!TabsStore.ensureLivingItem(tab) || // not removed while waiting + tab.$TST.parent != attachInfo.parent) // not detached while waiting + return; + + if (attachInfo.newlyAttached) + reserveToUpdateAncestors([tab].concat(tab.$TST.descendants)); + reserveToUpdateChildren(tab.$TST.parent); + reserveToUpdateInsertionPosition([ + tab, + tab.$TST.nextTab, + tab.$TST.previousTab + ]); +}); + +Tree.onDetached.addListener((tab, detachInfo) => { + reserveToUpdateAncestors([tab].concat(tab.$TST.descendants)); + reserveToUpdateChildren(detachInfo.oldParentTab); +}); + +Tree.onSubtreeCollapsedStateChanging.addListener((tab, _info) => { reserveToUpdateSubtreeCollapsed(tab); }); + + +const BASE_ICONS = { + '16': '/resources/16x16.svg', + '20': '/resources/20x20.svg', + '24': '/resources/24x24.svg', + '32': '/resources/32x32.svg', +}; +async function updateIconForBrowserTheme(theme) { + // generate icons with theme specific color + const toolbarIcons = {}; + const menuIcons = {}; + const sidebarIcons = {}; + + if (!theme) { + const win = await browser.windows.getLastFocused(); + theme = await browser.theme.getCurrent(win.id); + } + + log('updateIconForBrowserTheme: ', theme); + if (theme.colors) { + const toolbarIconColor = theme.colors.icons || theme.colors.toolbar_text || theme.colors.tab_text || theme.colors.tab_background_text || theme.colors.bookmark_text || theme.colors.textcolor; + const menuIconColor = theme.colors.popup_text || toolbarIconColor; + const sidebarIconColor = theme.colors.sidebar_text || toolbarIconColor; + log(' => ', { toolbarIconColor, menuIconColor, sidebarIconColor }, theme.colors); + await Promise.all(Array.from(Object.entries(BASE_ICONS), async ([size, url]) => { + const response = await fetch(url); + const body = await response.text(); + const toolbarIconSource = body.replace(/transparent\s*\/\*\s*TO BE REPLACED WITH THEME COLOR\s*\*\//g, toolbarIconColor); + toolbarIcons[size] = `data:image/svg+xml,${escape(toolbarIconSource)}#toolbar-theme`; + const menuIconSource = body.replace(/transparent\s*\/\*\s*TO BE REPLACED WITH THEME COLOR\s*\*\//g, menuIconColor); + menuIcons[size] = `data:image/svg+xml,${escape(menuIconSource)}#default-theme`; + const sidebarIconSource = body.replace(/transparent\s*\/\*\s*TO BE REPLACED WITH THEME COLOR\s*\*\//g, sidebarIconColor); + sidebarIcons[size] = `data:image/svg+xml,${escape(sidebarIconSource)}#default-theme`; + })); + } + else { + for (const [size, url] of Object.entries(BASE_ICONS)) { + toolbarIcons[size] = `${url}#toolbar`; + menuIcons[size] = sidebarIcons[size] = `${url}#default`; + } + } + + log('updateIconForBrowserTheme: applying icons: ', { + toolbarIcons, + menuIcons, + sidebarIcons, + }); + + await Promise.all([ + ...ContextMenu.getItemIdsWithIcon().map(id => browser.menus.update(id, { icons: menuIcons })), + browser.menus.refresh().catch(ApiTabs.createErrorSuppressor()), + browser.action?.setIcon({ path: toolbarIcons }), // Manifest v2 + browser.browserAction?.setIcon({ path: toolbarIcons }), // Manifest v3 + browser.sidebarAction?.setIcon({ path: sidebarIcons }), + ]); +} + +browser.theme.onUpdated.addListener(updateInfo => { + updateIconForBrowserTheme(updateInfo.theme); +}); + +mDarkModeMatchMedia.addListener(async _event => { + updateIconForBrowserTheme(); +}); + + + +configs.$addObserver(key => { + switch (key) { + case 'style': + updatePanelUrl(); + break; + + case 'debug': + EventListenerManager.debug = configs.debug; + break; + + case 'testKey': // for tests/utils.js + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TEST_KEY_CHANGED, + value: configs.testKey + }); + break; + } +}); diff --git a/waterfox/browser/components/sidebar/background/browser-action-menu.js b/waterfox/browser/components/sidebar/background/browser-action-menu.js new file mode 100644 index 000000000000..08594937f90a --- /dev/null +++ b/waterfox/browser/components/sidebar/background/browser-action-menu.js @@ -0,0 +1,2443 @@ +/* +# 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'; + +import { + log as internalLogger, + configs, + sanitizeAccesskeyMark, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from '/common/constants.js'; +import * as Permissions from '/common/permissions.js'; + +function log(...args) { + internalLogger('background/browser-action-menu', ...args); +} + +const delimiter = browser.i18n.getMessage('config_terms_delimiter'); + +function indent(level = 1) { + let result = ''; + for (let i = 0, maxi = level; i < maxi; i++) { + result += '\u00A0\u00A0\u00A0'; + } + return result; +} + +const mItems = [ + { + title: browser.i18n.getMessage('config_appearance_caption'), + children: [ + { + title: browser.i18n.getMessage('config_sidebarPosition_caption'), + children: [ + { + title: browser.i18n.getMessage('config_sidebarPosition_left'), + key: 'sidebarPosition', + value: Constants.kTABBAR_POSITION_LEFT, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_sidebarPosition_right'), + key: 'sidebarPosition', + value: Constants.kTABBAR_POSITION_RIGHT, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_sidebarPosition_auto'), + key: 'sidebarPosition', + value: Constants.kTABBAR_POSITION_AUTO, + type: 'radio' + }, + ] + }, + { + title: browser.i18n.getMessage('config_style_caption'), + children: [ + { + title: browser.i18n.getMessage('config_style_proton'), + key: 'style', + value: 'proton', + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_style_photon'), + key: 'style', + value: 'photon', + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_style_sidebar'), + key: 'style', + value: 'sidebar', + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_style_highcontrast'), + key: 'style', + value: 'highcontrast', + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_style_none'), + key: 'style', + value: 'none', + type: 'radio' + } + ] + }, + { + title: browser.i18n.getMessage('config_animation_label'), + key: 'animation', + type: 'checkbox' + }, + { + title: indent() + browser.i18n.getMessage('config_animationForce_label'), + key: 'animationForce', + type: 'checkbox', + expert: true + }, + { + title: browser.i18n.getMessage('config_labelOverflowStyle_caption'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_labelOverflowStyle_fade'), + key: 'labelOverflowStyle', + value: 'fade', + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_labelOverflowStyle_crop'), + key: 'labelOverflowStyle', + value: 'crop', + type: 'radio' + } + ], + }, + { + title: browser.i18n.getMessage('config_faviconizePinnedTabs_label'), + key: 'faviconizePinnedTabs', + type: 'checkbox', + expert: true + }, + { + title: browser.i18n.getMessage('config_tabPreviewTooltip_label'), + key: 'tabPreviewTooltip', + type: 'checkbox', + permissions: Permissions.ALL_URLS, + get canRevoke() { + return !configs.tabPreviewTooltip && !configs.skipCollapsedTabsForTabSwitchingShortcuts; + }, + }, + { + title: indent() + browser.i18n.getMessage('config_tabPreviewTooltipRenderIn_label_before') + browser.i18n.getMessage('config_tabPreviewTooltipRenderIn_anywhere') + browser.i18n.getMessage('config_tabPreviewTooltipRenderIn_label_after'), + key: 'tabPreviewTooltipRenderIn', + value: Constants.kIN_CONTENT_PANEL_RENDER_IN_ANYWHERE, + type: 'radio', + expert: true + }, + { + title: indent() + browser.i18n.getMessage('config_tabPreviewTooltipRenderIn_label_before') + browser.i18n.getMessage('config_tabPreviewTooltipRenderIn_content') + browser.i18n.getMessage('config_tabPreviewTooltipRenderIn_label_after'), + key: 'tabPreviewTooltipRenderIn', + value: Constants.kIN_CONTENT_PANEL_RENDER_IN_CONTENT, + type: 'radio', + expert: true + }, + { + title: indent() + browser.i18n.getMessage('config_tabPreviewTooltipRenderIn_label_before') + browser.i18n.getMessage('config_tabPreviewTooltipRenderIn_sidebar') + browser.i18n.getMessage('config_tabPreviewTooltipRenderIn_label_after'), + key: 'tabPreviewTooltipRenderIn', + value: Constants.kIN_CONTENT_PANEL_RENDER_IN_SIDEBAR, + type: 'radio', + expert: true + }, + { + title: browser.i18n.getMessage('config_showCollapsedDescendantsByTooltip_label'), + key: 'showCollapsedDescendantsByTooltip', + type: 'checkbox', + expert: true + }, + { + dynamicTitle: true, + get title() { + return browser.i18n.getMessage('config_shiftTabsForScrollbarDistance_label_before') + configs.shiftTabsForScrollbarDistance + browser.i18n.getMessage('config_shiftTabsForScrollbarDistance_label_after') + }, + enabled: false, + expert: true + }, + { + title: indent() + browser.i18n.getMessage('config_shiftTabsForScrollbarOnlyOnHover_label'), + key: 'shiftTabsForScrollbarOnlyOnHover', + type: 'checkbox', + expert: true + }, + { + title: browser.i18n.getMessage('config_suppressGapFromShownOrHiddenToolbar_caption'), + enabled: false, + expert: true + }, + { + title: indent() + browser.i18n.getMessage('config_suppressGapFromShownOrHiddenToolbarOnFullScreen_label'), + key: 'suppressGapFromShownOrHiddenToolbarOnFullScreen', + type: 'checkbox', + expert: true + }, + { + title: indent() + browser.i18n.getMessage('config_suppressGapFromShownOrHiddenToolbarOnNewTab_label'), + key: 'suppressGapFromShownOrHiddenToolbarOnNewTab', + type: 'checkbox', + expert: true + }, + { + title: indent() + browser.i18n.getMessage('config_suppressGapFromShownOrHiddenToolbarOnlyOnMouseOperation_label'), + key: 'suppressGapFromShownOrHiddenToolbarOnlyOnMouseOperation', + type: 'checkbox', + expert: true + }, + { type: 'separator', + expert: true, }, + { + title: browser.i18n.getMessage('config_showDialogInSidebar_label'), + key: 'showDialogInSidebar', + type: 'checkbox', + expert: true, + }, + { + title: browser.i18n.getMessage('config_renderHiddenTabs_label'), + key: 'renderHiddenTabs', + type: 'checkbox', + expert: true, + }, + ] + }, + { + title: browser.i18n.getMessage('config_context_caption'), + children: [ + { + title: browser.i18n.getMessage('config_extraItems_tabs_topLevel'), + enabled: false, + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_toggleSticky_command'), + key: 'context_topLevel_toggleSticky', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_reloadTree_command'), + key: 'context_topLevel_reloadTree', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_reloadDescendants_command'), + key: 'context_topLevel_reloadDescendants', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_unblockAutoplayTree_command'), + key: 'context_topLevel_unblockAutoplayTree', + type: 'checkbox', + get visible() { + return configs.exposeUnblockAutoplayFeatures; + }, + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_unblockAutoplayDescendants_command'), + key: 'context_topLevel_unblockAutoplayDescendants', + type: 'checkbox', + get visible() { + return configs.exposeUnblockAutoplayFeatures; + }, + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_toggleMuteTree_command'), + key: 'context_topLevel_toggleMuteTree', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_toggleMuteDescendants_command'), + key: 'context_topLevel_toggleMuteDescendants', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_closeTree_command'), + key: 'context_topLevel_closeTree', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_closeDescendants_command'), + key: 'context_topLevel_closeDescendants', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_closeOthers_command'), + key: 'context_topLevel_closeOthers', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_collapseTree_command'), + key: 'context_topLevel_collapseTree', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_collapseTreeRecursively_command'), + key: 'context_topLevel_collapseTreeRecursively', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_collapseAll_command'), + key: 'context_topLevel_collapseAll', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_expandTree_command'), + key: 'context_topLevel_expandTree', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_expandTreeRecursively_command'), + key: 'context_topLevel_expandTreeRecursively', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_expandAll_command'), + key: 'context_topLevel_expandAll', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_bookmarkTree_command'), + key: 'context_topLevel_bookmarkTree', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_sendTreeToDevice_command'), + key: 'context_topLevel_sendTreeToDevice', + type: 'checkbox', + expert: true, + }, + { type: 'separator', + expert: true, }, + { + title: browser.i18n.getMessage('config_requestPermissions_bookmarks'), + type: 'checkbox', + permissions: Permissions.BOOKMARKS + }, + { type: 'separator', + expert: true, }, + { + title: browser.i18n.getMessage('config_extraItems_tabs_subMenu'), + enabled: false, + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_toggleSticky_command'), + key: 'context_toggleSticky', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_reloadTree_command'), + key: 'context_reloadTree', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_reloadDescendants_command'), + key: 'context_reloadDescendants', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_unblockAutoplayTree_command'), + key: 'context_unblockAutoplayTree', + type: 'checkbox', + get visible() { + return configs.exposeUnblockAutoplayFeatures; + }, + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_unblockAutoplayDescendants_command'), + key: 'context_unblockAutoplayDescendants', + type: 'checkbox', + get visible() { + return configs.exposeUnblockAutoplayFeatures; + }, + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_toggleMuteTree_command'), + key: 'context_toggleMuteTree', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_toggleMuteDescendants_command'), + key: 'context_toggleMuteDescendants', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_closeTree_command'), + key: 'context_closeTree', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_closeDescendants_command'), + key: 'context_closeDescendants', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_closeOthers_command'), + key: 'context_closeOthers', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_collapseTree_command'), + key: 'context_collapseTree', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_collapseTreeRecursively_command'), + key: 'context_collapseTreeRecursively', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_collapseAll_command'), + key: 'context_collapseAll', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_expandTree_command'), + key: 'context_expandTree', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_expandTreeRecursively_command'), + key: 'context_expandTreeRecursively', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_expandAll_command'), + key: 'context_expandAll', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_bookmarkTree_command'), + key: 'context_bookmarkTree', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('context_sendTreeToDevice_command'), + key: 'context_sendTreeToDevice', + type: 'checkbox', + expert: true, + }, + { type: 'separator', + expert: true, }, + { + title: browser.i18n.getMessage('config_showTreeCommandsInTabsContextMenuGlobally_label'), + key: 'showTreeCommandsInTabsContextMenuGlobally', + type: 'checkbox', + expert: true, + }, + { type: 'separator', + expert: true, }, + { + title: browser.i18n.getMessage('config_extraItems_bookmarks_caption'), + enabled: false, + expert: true, + }, + { + title: indent() + sanitizeAccesskeyMark(browser.i18n.getMessage('context_openAllBookmarksWithStructure_label')), + key: 'context_openAllBookmarksWithStructure', + type: 'checkbox', + expert: true, + }, + { + title: indent() + sanitizeAccesskeyMark(browser.i18n.getMessage('context_openAllBookmarksWithStructureRecursively_label')), + key: 'context_openAllBookmarksWithStructureRecursively', + type: 'checkbox', + expert: true, + }, + { type: 'separator', + expert: true, }, + { + title: indent() + browser.i18n.getMessage('config_openAllBookmarksWithStructureDiscarded_label'), + key: 'openAllBookmarksWithStructureDiscarded', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('config_suppressGroupTabForStructuredTabsFromBookmarks_label'), + key: 'suppressGroupTabForStructuredTabsFromBookmarks', + type: 'checkbox', + expert: true, + }, + { type: 'separator', + expert: true }, + { + title: indent() + browser.i18n.getMessage('config_autoAttachOnContextNewTabCommand_before'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_autoAttachOnContextNewTabCommand_noControl') + delimiter + browser.i18n.getMessage('config_autoAttachOnContextNewTabCommand_after'), + key: 'autoAttachOnContextNewTabCommand', + value: Constants.kNEWTAB_DO_NOTHING, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnContextNewTabCommand_independent') + delimiter + browser.i18n.getMessage('config_autoAttachOnContextNewTabCommand_after'), + key: 'autoAttachOnContextNewTabCommand', + value: Constants.kNEWTAB_OPEN_AS_ORPHAN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnContextNewTabCommand_childTop') + delimiter + browser.i18n.getMessage('config_autoAttachOnContextNewTabCommand_after'), + key: 'autoAttachOnContextNewTabCommand', + value: Constants.kNEWTAB_OPEN_AS_CHILD_TOP, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnContextNewTabCommand_childEnd') + delimiter + browser.i18n.getMessage('config_autoAttachOnContextNewTabCommand_after'), + key: 'autoAttachOnContextNewTabCommand', + value: Constants.kNEWTAB_OPEN_AS_CHILD_END, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnContextNewTabCommand_sibling') + delimiter + browser.i18n.getMessage('config_autoAttachOnContextNewTabCommand_after'), + key: 'autoAttachOnContextNewTabCommand', + value: Constants.kNEWTAB_OPEN_AS_SIBLING, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnContextNewTabCommand_nextSibling') + delimiter + browser.i18n.getMessage('config_autoAttachOnContextNewTabCommand_after'), + key: 'autoAttachOnContextNewTabCommand', + value: Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnContextNewTabCommand_nextSiblingWithInheritedContainer') + delimiter + browser.i18n.getMessage('config_autoAttachOnContextNewTabCommand_after') + ' ' + browser.i18n.getMessage('config_firefoxCompatible_choice'), + key: 'autoAttachOnContextNewTabCommand', + value: Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING_WITH_INHERITED_CONTAINER, + type: 'radio' + } + ] + }, + ] + }, + { + title: browser.i18n.getMessage('config_newTabWithOwner_caption'), + children: [ + { + title: browser.i18n.getMessage('config_autoAttachOnOpenedWithOwner_before'), + children: [ + { + title: browser.i18n.getMessage('config_autoAttachOnOpenedWithOwner_noControl') + delimiter + browser.i18n.getMessage('config_autoAttachOnOpenedWithOwner_after'), + key: 'autoAttachOnOpenedWithOwner', + value: Constants.kNEWTAB_DO_NOTHING, + type: 'radio', + }, + { + title: browser.i18n.getMessage('config_autoAttachOnOpenedWithOwner_independent') + delimiter + browser.i18n.getMessage('config_autoAttachOnOpenedWithOwner_after'), + key: 'autoAttachOnOpenedWithOwner', + value: Constants.kNEWTAB_OPEN_AS_ORPHAN, + type: 'radio', + }, + { + title: browser.i18n.getMessage('config_autoAttachOnOpenedWithOwner_childTop') + delimiter + browser.i18n.getMessage('config_autoAttachOnOpenedWithOwner_after'), + key: 'autoAttachOnOpenedWithOwner', + value: Constants.kNEWTAB_OPEN_AS_CHILD_TOP, + type: 'radio', + }, + { + title: browser.i18n.getMessage('config_autoAttachOnOpenedWithOwner_childEnd') + delimiter + browser.i18n.getMessage('config_autoAttachOnOpenedWithOwner_after') + ' ' + browser.i18n.getMessage('config_recommended_choice'), + key: 'autoAttachOnOpenedWithOwner', + value: Constants.kNEWTAB_OPEN_AS_CHILD_END, + type: 'radio', + }, + { + title: browser.i18n.getMessage('config_autoAttachOnOpenedWithOwner_childNextToLastRelatedTab') + delimiter + browser.i18n.getMessage('config_autoAttachOnOpenedWithOwner_after') + ' ' + browser.i18n.getMessage('config_firefoxCompatible_choice'), + key: 'autoAttachOnOpenedWithOwner', + value: Constants.kNEWTAB_OPEN_AS_CHILD_NEXT_TO_LAST_RELATED_TAB, + type: 'radio', + }, + { + title: browser.i18n.getMessage('config_autoAttachOnOpenedWithOwner_sibling') + delimiter + browser.i18n.getMessage('config_autoAttachOnOpenedWithOwner_after'), + key: 'autoAttachOnOpenedWithOwner', + value: Constants.kNEWTAB_OPEN_AS_SIBLING, + type: 'radio', + }, + { + title: browser.i18n.getMessage('config_autoAttachOnOpenedWithOwner_nextSibling') + delimiter + browser.i18n.getMessage('config_autoAttachOnOpenedWithOwner_after'), + key: 'autoAttachOnOpenedWithOwner', + value: Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING, + type: 'radio', + }, + ], + }, + { + title: browser.i18n.getMessage('config_insertNewTabFromFirefoxViewAt_caption'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_insertNewTabFromFirefoxViewAt_noControl'), + key: 'insertNewTabFromFirefoxViewAt', + value: Constants.kINSERT_NO_CONTROL, + type: 'radio', + }, + { + title: browser.i18n.getMessage('config_insertNewTabFromFirefoxViewAt_nextToLastRelatedTab') + ' ' + browser.i18n.getMessage('config_firefoxCompatible_choice'), + key: 'insertNewTabFromFirefoxViewAt', + value: Constants.kINSERT_NEXT_TO_LAST_RELATED_TAB, + type: 'radio', + }, + { + title: browser.i18n.getMessage('config_insertNewTabFromFirefoxViewAt_top'), + key: 'insertNewTabFromFirefoxViewAt', + value: Constants.kINSERT_TOP, + type: 'radio', + }, + { + title: browser.i18n.getMessage('config_insertNewTabFromFirefoxViewAt_end'), + key: 'insertNewTabFromFirefoxViewAt', + value: Constants.kINSERT_END, + type: 'radio', + }, + ], + }, + //{ type: 'separator' }, + { + title: browser.i18n.getMessage('config_autoGroupNewTabsFromFirefoxView_label'), + key: 'autoGroupNewTabsFromFirefoxView', + type: 'checkbox', + expert: true, + }, + { + title: browser.i18n.getMessage('config_groupTabTemporaryStateForChildrenOfFirefoxView_label'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_groupTabTemporaryState_option_default'), + key: 'groupTabTemporaryStateForChildrenOfFirefoxView', + value: Constants.kGROUP_TAB_TEMPORARY_STATE_NOTHING, + type: 'radio' + }, + { + title: `${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_before')}${browser.i18n.getMessage('groupTab_temporary_label')}${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_after')}`, + key: 'groupTabTemporaryStateForChildrenOfFirefoxView', + value: Constants.kGROUP_TAB_TEMPORARY_STATE_PASSIVE, + type: 'radio' + }, + { + title: `${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_before')}${browser.i18n.getMessage('groupTab_temporaryAggressive_label')}${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_after')}`, + key: 'groupTabTemporaryStateForChildrenOfFirefoxView', + value: Constants.kGROUP_TAB_TEMPORARY_STATE_AGGRESSIVE, + type: 'radio' + } + ], + }, + { type: 'separator', + expert: true, }, + { + title: browser.i18n.getMessage('config_insertNewTabFromPinnedTabAt_caption'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_insertNewTabFromPinnedTabAt_noControl'), + key: 'insertNewTabFromPinnedTabAt', + value: Constants.kINSERT_NO_CONTROL, + type: 'radio', + }, + { + title: browser.i18n.getMessage('config_insertNewTabFromPinnedTabAt_nextToLastRelatedTab') + ' ' + browser.i18n.getMessage('config_firefoxCompatible_choice'), + key: 'insertNewTabFromPinnedTabAt', + value: Constants.kINSERT_NEXT_TO_LAST_RELATED_TAB, + type: 'radio', + }, + { + title: browser.i18n.getMessage('config_insertNewTabFromPinnedTabAt_top'), + key: 'insertNewTabFromPinnedTabAt', + value: Constants.kINSERT_TOP, + type: 'radio', + }, + { + title: browser.i18n.getMessage('config_insertNewTabFromPinnedTabAt_end'), + key: 'insertNewTabFromPinnedTabAt', + value: Constants.kINSERT_END, + type: 'radio', + }, + ], + }, + //{ type: 'separator' }, + { + title: browser.i18n.getMessage('config_autoGroupNewTabsFromPinned_label'), + key: 'autoGroupNewTabsFromPinned', + type: 'checkbox', + expert: true, + }, + { + title: browser.i18n.getMessage('config_groupTabTemporaryStateForChildrenOfPinned_label'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_groupTabTemporaryState_option_default'), + key: 'groupTabTemporaryStateForChildrenOfPinned', + value: Constants.kGROUP_TAB_TEMPORARY_STATE_NOTHING, + type: 'radio' + }, + { + title: `${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_before')}${browser.i18n.getMessage('groupTab_temporary_label')}${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_after')}`, + key: 'groupTabTemporaryStateForChildrenOfPinned', + value: Constants.kGROUP_TAB_TEMPORARY_STATE_PASSIVE, + type: 'radio' + }, + { + title: `${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_before')}${browser.i18n.getMessage('groupTab_temporaryAggressive_label')}${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_after')}`, + key: 'groupTabTemporaryStateForChildrenOfPinned', + value: Constants.kGROUP_TAB_TEMPORARY_STATE_AGGRESSIVE, + type: 'radio' + } + ] + }, + ] + }, + { + title: browser.i18n.getMessage('config_newTab_caption'), + children: [ + { + title: browser.i18n.getMessage('config_newTabAction_caption'), + enabled: false + }, + { + title: indent() + browser.i18n.getMessage('config_autoAttachOnNewTabCommand_before'), + children: [ + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabCommand_noControl') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabCommand_after') + ' ' + browser.i18n.getMessage('config_recommended_choice'), + key: 'autoAttachOnNewTabCommand', + value: Constants.kNEWTAB_DO_NOTHING, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabCommand_independent') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabCommand_after') + ' ' + browser.i18n.getMessage('config_firefoxCompatible_choice'), + key: 'autoAttachOnNewTabCommand', + value: Constants.kNEWTAB_OPEN_AS_ORPHAN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabCommand_childTop') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabCommand_after'), + key: 'autoAttachOnNewTabCommand', + value: Constants.kNEWTAB_OPEN_AS_CHILD_TOP, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabCommand_childEnd') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabCommand_after'), + key: 'autoAttachOnNewTabCommand', + value: Constants.kNEWTAB_OPEN_AS_CHILD_END, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabCommand_sibling') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabCommand_after'), + key: 'autoAttachOnNewTabCommand', + value: Constants.kNEWTAB_OPEN_AS_SIBLING, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabCommand_nextSibling') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabCommand_after'), + key: 'autoAttachOnNewTabCommand', + value: Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING, + type: 'radio' + } + ] + }, + { + title: indent() + browser.i18n.getMessage('config_inheritContextualIdentityToChildTabMode_label'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_inheritContextualIdentityToChildTabMode_default'), + key: 'inheritContextualIdentityToChildTabMode', + value: Constants.kCONTEXTUAL_IDENTITY_DEFAULT, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_inheritContextualIdentityToChildTabMode_parent'), + key: 'inheritContextualIdentityToChildTabMode', + value: Constants.kCONTEXTUAL_IDENTITY_FROM_PARENT, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_inheritContextualIdentityToChildTabMode_lastActive'), + key: 'inheritContextualIdentityToChildTabMode', + value: Constants.kCONTEXTUAL_IDENTITY_FROM_LAST_ACTIVE, + type: 'radio' + } + ] + }, + { type: 'separator', + expert: true, }, + { + title: browser.i18n.getMessage('config_newTabButton_caption'), + enabled: false, + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('config_autoAttachOnNewTabButtonMiddleClick_before'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabButtonMiddleClick_noControl') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabButtonMiddleClick_after'), + key: 'autoAttachOnNewTabButtonMiddleClick', + value: Constants.kNEWTAB_DO_NOTHING, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabButtonMiddleClick_independent') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabButtonMiddleClick_after'), + key: 'autoAttachOnNewTabButtonMiddleClick', + value: Constants.kNEWTAB_OPEN_AS_ORPHAN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabButtonMiddleClick_childTop') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabButtonMiddleClick_after'), + key: 'autoAttachOnNewTabButtonMiddleClick', + value: Constants.kNEWTAB_OPEN_AS_CHILD_TOP, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabButtonMiddleClick_childEnd') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabButtonMiddleClick_after'), + key: 'autoAttachOnNewTabButtonMiddleClick', + value: Constants.kNEWTAB_OPEN_AS_CHILD_END, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabButtonMiddleClick_sibling') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabButtonMiddleClick_after'), + key: 'autoAttachOnNewTabButtonMiddleClick', + value: Constants.kNEWTAB_OPEN_AS_SIBLING, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabButtonMiddleClick_nextSibling') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabButtonMiddleClick_after'), + key: 'autoAttachOnNewTabButtonMiddleClick', + value: Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabButtonMiddleClick_nextSiblingWithInheritedContainer') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabButtonMiddleClick_after') + ' ' + browser.i18n.getMessage('config_firefoxCompatible_choice'), + key: 'autoAttachOnNewTabButtonMiddleClick', + value: Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING_WITH_INHERITED_CONTAINER, + type: 'radio' + } + ], + }, + { + title: indent() + browser.i18n.getMessage('config_middleClickPasteURLOnNewTabButton_label'), + expert: true, + key: 'middleClickPasteURLOnNewTabButton', + type: 'checkbox', + permissions: Permissions.CLIPBOARD_READ, + }, + { + title: indent() + browser.i18n.getMessage('config_autoAttachOnNewTabButtonAccelClick_before'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabButtonAccelClick_noControl') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabButtonAccelClick_after'), + key: 'autoAttachOnNewTabButtonAccelClick', + value: Constants.kNEWTAB_DO_NOTHING, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabButtonAccelClick_independent') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabButtonAccelClick_after'), + key: 'autoAttachOnNewTabButtonAccelClick', + value: Constants.kNEWTAB_OPEN_AS_ORPHAN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabButtonAccelClick_childTop') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabButtonAccelClick_after'), + key: 'autoAttachOnNewTabButtonAccelClick', + value: Constants.kNEWTAB_OPEN_AS_CHILD_TOP, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabButtonAccelClick_childEnd') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabButtonAccelClick_after'), + key: 'autoAttachOnNewTabButtonAccelClick', + value: Constants.kNEWTAB_OPEN_AS_CHILD_END, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabButtonAccelClick_sibling') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabButtonAccelClick_after'), + key: 'autoAttachOnNewTabButtonAccelClick', + value: Constants.kNEWTAB_OPEN_AS_SIBLING, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabButtonAccelClick_nextSibling') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabButtonAccelClick_after'), + key: 'autoAttachOnNewTabButtonAccelClick', + value: Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnNewTabButtonAccelClick_nextSiblingWithInheritedContainer') + delimiter + browser.i18n.getMessage('config_autoAttachOnNewTabButtonAccelClick_after') + ' ' + browser.i18n.getMessage('config_firefoxCompatible_choice'), + key: 'autoAttachOnNewTabButtonAccelClick', + value: Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING_WITH_INHERITED_CONTAINER, + type: 'radio' + } + ] + }, + { + title: indent() + browser.i18n.getMessage('config_longPressOnNewTabButton_before'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_longPressOnNewTabButton_newTabAction') + delimiter + browser.i18n.getMessage('config_longPressOnNewTabButton_after'), + key: 'longPressOnNewTabButton', + value: 'newtab-action-selector', + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_longPressOnNewTabButton_contextualIdentities') + delimiter + browser.i18n.getMessage('config_longPressOnNewTabButton_after'), + key: 'longPressOnNewTabButton', + value: 'contextual-identities-selector', + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_longPressOnNewTabButton_none') + delimiter + browser.i18n.getMessage('config_longPressOnNewTabButton_after'), + key: 'longPressOnNewTabButton', + value: '', + type: 'radio' + } + ], + }, + { + title: browser.i18n.getMessage('config_showNewTabActionSelector_label'), + key: 'showNewTabActionSelector', + type: 'checkbox', + expert: true, + }, + { + title: browser.i18n.getMessage('config_showContextualIdentitiesSelector_label'), + key: 'showContextualIdentitiesSelector', + type: 'checkbox', + expert: true, + }, + { type: 'separator', + expert: true, }, + { + title: browser.i18n.getMessage('config_autoAttachWithURL_caption'), + enabled: false, + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('config_autoAttachOnDuplicated_before'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_autoAttachOnDuplicated_noControl') + delimiter + browser.i18n.getMessage('config_autoAttachOnDuplicated_after'), + key: 'autoAttachOnDuplicated', + value: Constants.kNEWTAB_DO_NOTHING, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnDuplicated_independent') + delimiter + browser.i18n.getMessage('config_autoAttachOnDuplicated_after'), + key: 'autoAttachOnDuplicated', + value: Constants.kNEWTAB_OPEN_AS_ORPHAN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnDuplicated_childTop') + delimiter + browser.i18n.getMessage('config_autoAttachOnDuplicated_after'), + key: 'autoAttachOnDuplicated', + value: Constants.kNEWTAB_OPEN_AS_CHILD_TOP, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnDuplicated_childEnd') + delimiter + browser.i18n.getMessage('config_autoAttachOnDuplicated_after'), + key: 'autoAttachOnDuplicated', + value: Constants.kNEWTAB_OPEN_AS_CHILD_END, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnDuplicated_sibling') + delimiter + browser.i18n.getMessage('config_autoAttachOnDuplicated_after'), + key: 'autoAttachOnDuplicated', + value: Constants.kNEWTAB_OPEN_AS_SIBLING, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnDuplicated_nextSibling') + delimiter + browser.i18n.getMessage('config_autoAttachOnDuplicated_after') + ' ' + browser.i18n.getMessage('config_recommended_choice'), + key: 'autoAttachOnDuplicated', + value: Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING, + type: 'radio' + } + ] + }, + { + title: indent() + browser.i18n.getMessage('config_sameSiteOrphan_caption'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_autoAttachSameSiteOrphan_before') + delimiter + browser.i18n.getMessage('config_autoAttachSameSiteOrphan_noControl') + delimiter + browser.i18n.getMessage('config_autoAttachSameSiteOrphan_after'), + key: 'autoAttachSameSiteOrphan', + value: Constants.kNEWTAB_DO_NOTHING, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachSameSiteOrphan_before') + delimiter + browser.i18n.getMessage('config_autoAttachSameSiteOrphan_independent') + delimiter + browser.i18n.getMessage('config_autoAttachSameSiteOrphan_after'), + key: 'autoAttachSameSiteOrphan', + value: Constants.kNEWTAB_OPEN_AS_ORPHAN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachSameSiteOrphan_before') + delimiter + browser.i18n.getMessage('config_autoAttachSameSiteOrphan_childTop') + delimiter + browser.i18n.getMessage('config_autoAttachSameSiteOrphan_after'), + key: 'autoAttachSameSiteOrphan', + value: Constants.kNEWTAB_OPEN_AS_CHILD_TOP, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachSameSiteOrphan_before') + delimiter + browser.i18n.getMessage('config_autoAttachSameSiteOrphan_childEnd') + delimiter + browser.i18n.getMessage('config_autoAttachSameSiteOrphan_after') + ' ' + browser.i18n.getMessage('config_recommended_choice'), + key: 'autoAttachSameSiteOrphan', + value: Constants.kNEWTAB_OPEN_AS_CHILD_END, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachSameSiteOrphan_before') + delimiter + browser.i18n.getMessage('config_autoAttachSameSiteOrphan_sibling') + delimiter + browser.i18n.getMessage('config_autoAttachSameSiteOrphan_after'), + key: 'autoAttachSameSiteOrphan', + value: Constants.kNEWTAB_OPEN_AS_SIBLING, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachSameSiteOrphan_before') + delimiter + browser.i18n.getMessage('config_autoAttachSameSiteOrphan_nextSibling') + delimiter + browser.i18n.getMessage('config_autoAttachSameSiteOrphan_after'), + key: 'autoAttachSameSiteOrphan', + value: Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING, + type: 'radio' + }, + { type: 'separator' }, + { + title: browser.i18n.getMessage('config_inheritContextualIdentityToSameSiteOrphanMode_label'), + children: [ + { + title: browser.i18n.getMessage('config_inheritContextualIdentityToSameSiteOrphanMode_default'), + key: 'inheritContextualIdentityToSameSiteOrphanMode', + value: Constants.kCONTEXTUAL_IDENTITY_DEFAULT, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_inheritContextualIdentityToSameSiteOrphanMode_parent'), + key: 'inheritContextualIdentityToSameSiteOrphanMode', + value: Constants.kCONTEXTUAL_IDENTITY_FROM_PARENT, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_inheritContextualIdentityToSameSiteOrphanMode_lastActive'), + key: 'inheritContextualIdentityToSameSiteOrphanMode', + value: Constants.kCONTEXTUAL_IDENTITY_FROM_LAST_ACTIVE, + type: 'radio' + } + ] + } + ] + }, + { + title: indent() + browser.i18n.getMessage('config_fromExternal_caption'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_autoAttachOnOpenedFromExternal_before') + delimiter + browser.i18n.getMessage('config_autoAttachOnOpenedFromExternal_noControl') + delimiter + browser.i18n.getMessage('config_autoAttachOnOpenedFromExternal_after') + ' ' + browser.i18n.getMessage('config_recommended_choice'), + key: 'autoAttachOnOpenedFromExternal', + value: Constants.kNEWTAB_DO_NOTHING, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnOpenedFromExternal_before') + delimiter + browser.i18n.getMessage('config_autoAttachOnOpenedFromExternal_independent') + delimiter + browser.i18n.getMessage('config_autoAttachOnOpenedFromExternal_after'), + key: 'autoAttachOnOpenedFromExternal', + value: Constants.kNEWTAB_OPEN_AS_ORPHAN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnOpenedFromExternal_before') + delimiter + browser.i18n.getMessage('config_autoAttachOnOpenedFromExternal_childTop') + delimiter + browser.i18n.getMessage('config_autoAttachOnOpenedFromExternal_after'), + key: 'autoAttachOnOpenedFromExternal', + value: Constants.kNEWTAB_OPEN_AS_CHILD_TOP, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnOpenedFromExternal_before') + delimiter + browser.i18n.getMessage('config_autoAttachOnOpenedFromExternal_childEnd') + delimiter + browser.i18n.getMessage('config_autoAttachOnOpenedFromExternal_after'), + key: 'autoAttachOnOpenedFromExternal', + value: Constants.kNEWTAB_OPEN_AS_CHILD_END, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnOpenedFromExternal_before') + delimiter + browser.i18n.getMessage('config_autoAttachOnOpenedFromExternal_sibling') + delimiter + browser.i18n.getMessage('config_autoAttachOnOpenedFromExternal_after'), + key: 'autoAttachOnOpenedFromExternal', + value: Constants.kNEWTAB_OPEN_AS_SIBLING, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnOpenedFromExternal_before') + delimiter + browser.i18n.getMessage('config_autoAttachOnOpenedFromExternal_nextSibling') + delimiter + browser.i18n.getMessage('config_autoAttachOnOpenedFromExternal_after'), + key: 'autoAttachOnOpenedFromExternal', + value: Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING, + type: 'radio' + }, + { type: 'separator' }, + { + title: browser.i18n.getMessage('config_inheritContextualIdentityToTabsFromExternalMode_label'), + children: [ + { + title: browser.i18n.getMessage('config_inheritContextualIdentityToTabsFromExternalMode_default'), + key: 'inheritContextualIdentityToTabsFromExternalMode', + value: Constants.kCONTEXTUAL_IDENTITY_DEFAULT, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_inheritContextualIdentityToTabsFromExternalMode_parent'), + key: 'inheritContextualIdentityToTabsFromExternalMode', + value: Constants.kCONTEXTUAL_IDENTITY_FROM_PARENT, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_inheritContextualIdentityToTabsFromExternalMode_lastActive'), + key: 'inheritContextualIdentityToTabsFromExternalMode', + value: Constants.kCONTEXTUAL_IDENTITY_FROM_LAST_ACTIVE, + type: 'radio' + } + ] + } + ] + }, + { type: 'separator', + expert: true, }, + { + title: indent() + browser.i18n.getMessage('config_anyOtherTrigger_caption'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_autoAttachOnAnyOtherTrigger_before') + delimiter + browser.i18n.getMessage('config_autoAttachOnAnyOtherTrigger_noControl') + delimiter + browser.i18n.getMessage('config_autoAttachOnAnyOtherTrigger_after') + ' ' + browser.i18n.getMessage('config_recommended_choice'), + key: 'autoAttachOnAnyOtherTrigger', + value: Constants.kNEWTAB_DO_NOTHING, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnAnyOtherTrigger_before') + delimiter + browser.i18n.getMessage('config_autoAttachOnAnyOtherTrigger_independent') + delimiter + browser.i18n.getMessage('config_autoAttachOnAnyOtherTrigger_after'), + key: 'autoAttachOnAnyOtherTrigger', + value: Constants.kNEWTAB_OPEN_AS_ORPHAN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnAnyOtherTrigger_before') + delimiter + browser.i18n.getMessage('config_autoAttachOnAnyOtherTrigger_childTop') + delimiter + browser.i18n.getMessage('config_autoAttachOnAnyOtherTrigger_after'), + key: 'autoAttachOnAnyOtherTrigger', + value: Constants.kNEWTAB_OPEN_AS_CHILD_TOP, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnAnyOtherTrigger_before') + delimiter + browser.i18n.getMessage('config_autoAttachOnAnyOtherTrigger_childEnd') + delimiter + browser.i18n.getMessage('config_autoAttachOnAnyOtherTrigger_after'), + key: 'autoAttachOnAnyOtherTrigger', + value: Constants.kNEWTAB_OPEN_AS_CHILD_END, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnAnyOtherTrigger_before') + delimiter + browser.i18n.getMessage('config_autoAttachOnAnyOtherTrigger_sibling') + delimiter + browser.i18n.getMessage('config_autoAttachOnAnyOtherTrigger_after'), + key: 'autoAttachOnAnyOtherTrigger', + value: Constants.kNEWTAB_OPEN_AS_SIBLING, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_autoAttachOnAnyOtherTrigger_before') + delimiter + browser.i18n.getMessage('config_autoAttachOnAnyOtherTrigger_nextSibling') + delimiter + browser.i18n.getMessage('config_autoAttachOnAnyOtherTrigger_after'), + key: 'autoAttachOnAnyOtherTrigger', + value: Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING, + type: 'radio' + }, + { type: 'separator' }, + { + title: browser.i18n.getMessage('config_inheritContextualIdentityToTabsFromAnyOtherTriggerMode_label'), + get enabled() { + return configs.autoAttachOnAnyOtherTrigger != Constants.kNEWTAB_DO_NOTHING; + }, + children: [ + { + title: browser.i18n.getMessage('config_inheritContextualIdentityToTabsFromAnyOtherTriggerMode_default'), + key: 'inheritContextualIdentityToTabsFromAnyOtherTriggerMode', + value: Constants.kCONTEXTUAL_IDENTITY_DEFAULT, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_inheritContextualIdentityToTabsFromAnyOtherTriggerMode_parent'), + key: 'inheritContextualIdentityToTabsFromAnyOtherTriggerMode', + value: Constants.kCONTEXTUAL_IDENTITY_FROM_PARENT, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_inheritContextualIdentityToTabsFromAnyOtherTriggerMode_lastActive'), + key: 'inheritContextualIdentityToTabsFromAnyOtherTriggerMode', + value: Constants.kCONTEXTUAL_IDENTITY_FROM_LAST_ACTIVE, + type: 'radio' + } + ] + } + ] + }, + { type: 'separator', + expert: true, }, + { + title: browser.i18n.getMessage('config_groupTab_caption'), + enabled: false, + expert: true, + }, + { + dynamicTitle: true, + get title() { + return indent() + browser.i18n.getMessage('config_tabBunchesDetectionTimeout_before') + delimiter + configs.tabBunchesDetectionTimeout + delimiter + browser.i18n.getMessage('config_tabBunchesDetectionTimeout_after'); + }, + enabled: false, + expert: true, + }, + { + title: indent(2) + browser.i18n.getMessage('config_autoGroupNewTabsFromBookmarks_label'), + key: 'autoGroupNewTabsFromBookmarks', + type: 'checkbox', + expert: true + }, + { + title: indent(2) + browser.i18n.getMessage('config_restoreTreeForTabsFromBookmarks_label'), + key: 'restoreTreeForTabsFromBookmarks', + type: 'checkbox', + expert: true + }, + { + title: indent() + browser.i18n.getMessage('config_autoGroupNewTabsFromOthers_label'), + key: 'autoGroupNewTabsFromOthers', + type: 'checkbox', + expert: true + }, + { + title: indent(2) + browser.i18n.getMessage('config_warnOnAutoGroupNewTabs_label'), + key: 'warnOnAutoGroupNewTabs', + type: 'checkbox', + expert: true + }, + { + title: indent(4) + browser.i18n.getMessage('config_warnOnAutoGroupNewTabsWithListing_label'), + key: 'warnOnAutoGroupNewTabsWithListing', + type: 'checkbox', + expert: true + }, + { type: 'separator', + expert: true }, + { + title: indent() + browser.i18n.getMessage('config_renderTreeInGroupTabs_label'), + key: 'renderTreeInGroupTabs', + type: 'checkbox', + expert: true + }, + { type: 'separator', + expert: true }, + { + title: browser.i18n.getMessage('config_groupTabTemporaryState_caption'), + enabled: false, + expert: true + }, + { + title: indent() + browser.i18n.getMessage('config_groupTabTemporaryStateForNewTabsFromBookmarks_label'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_groupTabTemporaryState_option_default'), + key: 'groupTabTemporaryStateForNewTabsFromBookmarks', + value: Constants.kGROUP_TAB_TEMPORARY_STATE_NOTHING, + type: 'radio' + }, + { + title: `${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_before')}${browser.i18n.getMessage('groupTab_temporary_label')}${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_after')}`, + key: 'groupTabTemporaryStateForNewTabsFromBookmarks', + value: Constants.kGROUP_TAB_TEMPORARY_STATE_PASSIVE, + type: 'radio' + }, + { + title: `${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_before')}${browser.i18n.getMessage('groupTab_temporaryAggressive_label')}${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_after')}`, + key: 'groupTabTemporaryStateForNewTabsFromBookmarks', + value: Constants.kGROUP_TAB_TEMPORARY_STATE_AGGRESSIVE, + type: 'radio' + } + ] + }, + { + title: indent() + browser.i18n.getMessage('config_groupTabTemporaryStateForNewTabsFromOthers_label'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_groupTabTemporaryState_option_default'), + key: 'groupTabTemporaryStateForNewTabsFromOthers', + value: Constants.kGROUP_TAB_TEMPORARY_STATE_NOTHING, + type: 'radio' + }, + { + title: `${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_before')}${browser.i18n.getMessage('groupTab_temporary_label')}${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_after')}`, + key: 'groupTabTemporaryStateForNewTabsFromOthers', + value: Constants.kGROUP_TAB_TEMPORARY_STATE_PASSIVE, + type: 'radio' + }, + { + title: `${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_before')}${browser.i18n.getMessage('groupTab_temporaryAggressive_label')}${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_after')}`, + key: 'groupTabTemporaryStateForNewTabsFromOthers', + value: Constants.kGROUP_TAB_TEMPORARY_STATE_AGGRESSIVE, + type: 'radio' + } + ] + }, + { + title: indent() + browser.i18n.getMessage('config_groupTabTemporaryStateForOrphanedTabs_label'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_groupTabTemporaryState_option_default'), + key: 'groupTabTemporaryStateForOrphanedTabs', + value: Constants.kGROUP_TAB_TEMPORARY_STATE_NOTHING, + type: 'radio' + }, + { + title: `${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_before')}${browser.i18n.getMessage('groupTab_temporary_label')}${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_after')}`, + key: 'groupTabTemporaryStateForOrphanedTabs', + value: Constants.kGROUP_TAB_TEMPORARY_STATE_PASSIVE, + type: 'radio' + }, + { + title: `${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_before')}${browser.i18n.getMessage('groupTab_temporaryAggressive_label')}${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_after')}`, + key: 'groupTabTemporaryStateForOrphanedTabs', + value: Constants.kGROUP_TAB_TEMPORARY_STATE_AGGRESSIVE, + type: 'radio' + } + ] + }, + { + title: indent() + browser.i18n.getMessage('config_groupTabTemporaryStateForAPI_label'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_groupTabTemporaryState_option_default'), + key: 'groupTabTemporaryStateForAPI', + value: Constants.kGROUP_TAB_TEMPORARY_STATE_NOTHING, + type: 'radio' + }, + { + title: `${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_before')}${browser.i18n.getMessage('groupTab_temporary_label')}${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_after')}`, + key: 'groupTabTemporaryStateForAPI', + value: Constants.kGROUP_TAB_TEMPORARY_STATE_PASSIVE, + type: 'radio' + }, + { + title: `${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_before')}${browser.i18n.getMessage('groupTab_temporaryAggressive_label')}${browser.i18n.getMessage('config_groupTabTemporaryState_option_checked_after')}`, + key: 'groupTabTemporaryStateForAPI', + value: Constants.kGROUP_TAB_TEMPORARY_STATE_AGGRESSIVE, + type: 'radio' + }, + ], + }, + ], + }, + { + title: browser.i18n.getMessage('config_treeBehavior_caption'), + children: [ + { + title: browser.i18n.getMessage('config_autoCollapseExpandSubtreeOnAttach_label'), + key: 'autoCollapseExpandSubtreeOnAttach', + type: 'checkbox', + expert: true, + }, + { + title: browser.i18n.getMessage('config_autoCollapseExpandSubtreeOnSelect_label'), + key: 'autoCollapseExpandSubtreeOnSelect', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('config_autoCollapseExpandSubtreeOnSelectExceptActiveTabRemove_label'), + key: 'autoCollapseExpandSubtreeOnSelectExceptActiveTabRemove', + type: 'checkbox', + expert: true + }, + { + title: browser.i18n.getMessage('config_unfocusableCollapsedTab_label'), + key: 'unfocusableCollapsedTab', + type: 'checkbox', + expert: true + }, + { + title: browser.i18n.getMessage('config_autoDiscardTabForUnexpectedFocus_label'), + key: 'autoDiscardTabForUnexpectedFocus', + type: 'checkbox', + expert: true + }, + { + title: browser.i18n.getMessage('config_avoidDiscardedTabToBeActivatedIfPossible_label'), + key: 'avoidDiscardedTabToBeActivatedIfPossible', + type: 'checkbox', + expert: true, + }, + { + title: browser.i18n.getMessage('config_requestPermissions_allUrls_ctrlTabTracking'), + key: 'skipCollapsedTabsForTabSwitchingShortcuts', + type: 'checkbox', + permissions: Permissions.ALL_URLS, + get canRevoke() { + return !configs.tabPreviewTooltip && !configs.skipCollapsedTabsForTabSwitchingShortcuts; + }, + }, + { + dynamicTitle: true, + get title() { + return indent() + browser.i18n.getMessage('config_autoExpandOnTabSwitchingShortcutsDelay_before') + delimiter + configs.autoExpandOnTabSwitchingShortcutsDelay + delimiter + browser.i18n.getMessage('config_autoExpandOnTabSwitchingShortcutsDelay_after'); + }, + key: 'autoExpandOnTabSwitchingShortcuts', + type: 'checkbox', + expert: true + }, + { + title: indent() + browser.i18n.getMessage('config_accelKey_label'), + enabled: false, + expert: true + }, + { + title: indent(2) + browser.i18n.getMessage('config_accelKey_auto'), + key: 'accelKey', + value: '', + type: 'radio', + expert: true, + }, + { + title: indent(2) + browser.i18n.getMessage('config_accelKey_alt'), + key: 'accelKey', + value: 'alt', + type: 'radio', + expert: true, + }, + { + title: indent(2) + browser.i18n.getMessage('config_accelKey_control'), + key: 'accelKey', + value: 'control', + type: 'radio', + expert: true, + }, + { + title: indent(2) + browser.i18n.getMessage('config_accelKey_meta'), + key: 'accelKey', + value: 'meta', + type: 'radio', + expert: true, + }, + { type: 'separator', expert: true }, + { + title: browser.i18n.getMessage('config_treeDoubleClickBehavior_caption'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_treeDoubleClickBehavior_toggleCollapsed'), + key: 'treeDoubleClickBehavior', + value: Constants.kTREE_DOUBLE_CLICK_BEHAVIOR_TOGGLE_COLLAPSED, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_treeDoubleClickBehavior_toggleSticky'), + key: 'treeDoubleClickBehavior', + value: Constants.kTREE_DOUBLE_CLICK_BEHAVIOR_TOGGLE_STICKY, + type: 'radio' + }, + { + title: `${browser.i18n.getMessage('config_treeDoubleClickBehavior_close')}${browser.i18n.getMessage('config_treeDoubleClickBehavior_close_note')}`, + key: 'treeDoubleClickBehavior', + value: Constants.kTREE_DOUBLE_CLICK_BEHAVIOR_CLOSE, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_treeDoubleClickBehavior_none'), + key: 'treeDoubleClickBehavior', + value: Constants.kTREE_DOUBLE_CLICK_BEHAVIOR_NONE, + type: 'radio' + } + ] + }, + { + title: browser.i18n.getMessage('config_successorTabControlLevel_caption'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_successorTabControlLevel_inTree'), + key: 'successorTabControlLevel', + value: Constants.kSUCCESSOR_TAB_CONTROL_IN_TREE, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_successorTabControlLevel_simulateDefault'), + key: 'successorTabControlLevel', + value: Constants.kSUCCESSOR_TAB_CONTROL_SIMULATE_DEFAULT, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_successorTabControlLevel_never'), + key: 'successorTabControlLevel', + value: Constants.kSUCCESSOR_TAB_CONTROL_NEVER, + type: 'radio' + } + ] + }, + { + title: browser.i18n.getMessage('config_simulateSelectOwnerOnClose_label'), + key: 'simulateSelectOwnerOnClose', + type: 'checkbox', + get visible() { + return typeof browser.tabs.moveInSuccession == 'function'; + }, + expert: true, + }, + { + title: browser.i18n.getMessage('config_parentTabOperationBehaviorMode_caption'), + children: [ + { + title: browser.i18n.getMessage('config_parentTabOperationBehaviorMode_parallel'), + key: 'parentTabOperationBehaviorMode', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_MODE_PARALLEL, + type: 'radio' + }, + { + title: indent() + browser.i18n.getMessage('config_closeParentBehavior_insideSidebar'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_parentTabOperationBehavior_replaceWithGroupTab'), + key: 'closeParentBehavior_insideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_parentTabOperationBehavior_promoteFirst'), + key: 'closeParentBehavior_insideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_parentTabOperationBehavior_promoteAll'), + key: 'closeParentBehavior_insideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_parentTabOperationBehavior_promoteIntelligently'), + key: 'closeParentBehavior_insideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY, + type: 'radio' + } + ] + }, + { + title: indent() + browser.i18n.getMessage('config_closeParentBehavior_outsideSidebar'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_parentTabOperationBehavior_replaceWithGroupTab'), + key: 'closeParentBehavior_outsideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_parentTabOperationBehavior_promoteFirst'), + key: 'closeParentBehavior_outsideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_parentTabOperationBehavior_promoteAll'), + key: 'closeParentBehavior_outsideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_parentTabOperationBehavior_promoteIntelligently'), + key: 'closeParentBehavior_outsideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY, + type: 'radio' + } + ] + }, + { type: 'separator', + expert: true, }, + { + title: browser.i18n.getMessage('config_parentTabOperationBehaviorMode_consistent'), + key: 'parentTabOperationBehaviorMode', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_MODE_CONSISTENT, + type: 'radio' + }, + { + title: indent() + browser.i18n.getMessage('config_parentTabOperationBehaviorMode_consistent_caption'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_parentTabOperationBehavior_replaceWithGroupTab'), + key: 'closeParentBehavior_insideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_parentTabOperationBehavior_promoteFirst'), + key: 'closeParentBehavior_insideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_parentTabOperationBehavior_promoteAll'), + key: 'closeParentBehavior_insideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_parentTabOperationBehavior_promoteIntelligently'), + key: 'closeParentBehavior_insideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY, + type: 'radio' + } + ] + }, + { type: 'separator', + expert: true, }, + { + title: browser.i18n.getMessage('config_parentTabOperationBehaviorMode_custom'), + key: 'parentTabOperationBehaviorMode', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_MODE_CUSTOM, + type: 'radio', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('config_closeParentBehavior_insideSidebar_expanded_caption'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_closeParentBehavior_entireTree'), + key: 'closeParentBehavior_insideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_replaceWithGroupTab'), + key: 'closeParentBehavior_insideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_promoteFirst'), + key: 'closeParentBehavior_insideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_promoteAll'), + key: 'closeParentBehavior_insideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_promoteIntelligently'), + key: 'closeParentBehavior_insideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_detach'), + key: 'closeParentBehavior_insideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_DETACH_ALL_CHILDREN, + type: 'radio' + } + ] + }, + { + title: indent() + browser.i18n.getMessage('config_closeParentBehavior_outsideSidebar_collapsed_caption'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_closeParentBehavior_entireTree'), + key: 'closeParentBehavior_outsideSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_replaceWithGroupTab'), + key: 'closeParentBehavior_outsideSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_promoteFirst'), + key: 'closeParentBehavior_outsideSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_promoteAll'), + key: 'closeParentBehavior_outsideSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_promoteIntelligently'), + key: 'closeParentBehavior_outsideSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_detach'), + key: 'closeParentBehavior_outsideSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_DETACH_ALL_CHILDREN, + type: 'radio' + } + ] + }, + { + title: indent(2) + browser.i18n.getMessage('config_closeParentBehavior_noSidebar_collapsed_caption'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_closeParentBehavior_entireTree'), + key: 'closeParentBehavior_noSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_replaceWithGroupTab'), + key: 'closeParentBehavior_noSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_promoteFirst'), + key: 'closeParentBehavior_noSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_promoteAll'), + key: 'closeParentBehavior_noSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_promoteIntelligently'), + key: 'closeParentBehavior_noSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_detach'), + key: 'closeParentBehavior_noSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_DETACH_ALL_CHILDREN, + type: 'radio' + } + ] + }, + { + title: indent() + browser.i18n.getMessage('config_closeParentBehavior_outsideSidebar_expanded_caption'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_closeParentBehavior_entireTree'), + key: 'closeParentBehavior_outsideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_replaceWithGroupTab'), + key: 'closeParentBehavior_outsideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_promoteFirst'), + key: 'closeParentBehavior_outsideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_promoteAll'), + key: 'closeParentBehavior_outsideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_promoteIntelligently'), + key: 'closeParentBehavior_outsideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_detach'), + key: 'closeParentBehavior_outsideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_DETACH_ALL_CHILDREN, + type: 'radio' + } + ] + }, + { + title: indent(2) + browser.i18n.getMessage('config_closeParentBehavior_noSidebar_expanded_caption'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_closeParentBehavior_entireTree'), + key: 'closeParentBehavior_noSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_replaceWithGroupTab'), + key: 'closeParentBehavior_noSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_promoteFirst'), + key: 'closeParentBehavior_noSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_promoteAll'), + key: 'closeParentBehavior_noSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_promoteIntelligently'), + key: 'closeParentBehavior_noSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_closeParentBehavior_detach'), + key: 'closeParentBehavior_noSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_DETACH_ALL_CHILDREN, + type: 'radio' + } + ] + }, + { + title: indent() + browser.i18n.getMessage('config_moveParentBehavior_outsideSidebar_collapsed_caption'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_moveParentBehavior_entireTree'), + key: 'moveParentBehavior_outsideSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_replaceWithGroupTab'), + key: 'moveParentBehavior_outsideSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_promoteFirst'), + key: 'moveParentBehavior_outsideSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_promoteAll'), + key: 'moveParentBehavior_outsideSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_promoteIntelligently'), + key: 'moveParentBehavior_outsideSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_detach'), + key: 'moveParentBehavior_outsideSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_DETACH_ALL_CHILDREN, + type: 'radio' + } + ] + }, + { + title: indent(2) + browser.i18n.getMessage('config_moveParentBehavior_noSidebar_collapsed_caption'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_moveParentBehavior_entireTree'), + key: 'moveParentBehavior_noSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_replaceWithGroupTab'), + key: 'moveParentBehavior_noSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_promoteFirst'), + key: 'moveParentBehavior_noSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_promoteAll'), + key: 'moveParentBehavior_noSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_promoteIntelligently'), + key: 'moveParentBehavior_noSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_detach'), + key: 'moveParentBehavior_noSidebar_collapsed', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_DETACH_ALL_CHILDREN, + type: 'radio' + } + ] + }, + { + title: indent() + browser.i18n.getMessage('config_moveParentBehavior_outsideSidebar_expanded_caption'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_moveParentBehavior_entireTree'), + key: 'moveParentBehavior_outsideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_replaceWithGroupTab'), + key: 'moveParentBehavior_outsideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_promoteFirst'), + key: 'moveParentBehavior_outsideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_promoteAll'), + key: 'moveParentBehavior_outsideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_promoteIntelligently'), + key: 'moveParentBehavior_outsideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_detach'), + key: 'moveParentBehavior_outsideSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_DETACH_ALL_CHILDREN, + type: 'radio' + } + ] + }, + { + title: indent(2) + browser.i18n.getMessage('config_moveParentBehavior_noSidebar_expanded_caption'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_moveParentBehavior_entireTree'), + key: 'moveParentBehavior_noSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_replaceWithGroupTab'), + key: 'moveParentBehavior_noSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_promoteFirst'), + key: 'moveParentBehavior_noSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_promoteAll'), + key: 'moveParentBehavior_noSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_promoteIntelligently'), + key: 'moveParentBehavior_noSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_moveParentBehavior_detach'), + key: 'moveParentBehavior_noSidebar_expanded', + value: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_DETACH_ALL_CHILDREN, + type: 'radio' + } + ] + } + ] + }, + { + title: browser.i18n.getMessage('config_fixupTreeOnTabVisibilityChanged_caption'), + children: [ + { + title: browser.i18n.getMessage('config_fixupTreeOnTabVisibilityChanged_fix'), + key: 'fixupTreeOnTabVisibilityChanged', + value: true, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_fixupTreeOnTabVisibilityChanged_keep'), + key: 'fixupTreeOnTabVisibilityChanged', + value: false, + type: 'radio' + } + ] + }, + { + title: browser.i18n.getMessage('config_insertNewChildAt_caption'), + expert: true, + children: [ + { + title: browser.i18n.getMessage('config_insertNewChildAt_noControl'), + key: 'insertNewChildAt', + value: Constants.kINSERT_NO_CONTROL, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_insertNewChildAt_nextToLastRelatedTab'), + key: 'insertNewChildAt', + value: Constants.kINSERT_NEXT_TO_LAST_RELATED_TAB, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_insertNewChildAt_top'), + key: 'insertNewChildAt', + value: Constants.kINSERT_TOP, + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_insertNewChildAt_end'), + key: 'insertNewChildAt', + value: Constants.kINSERT_END, + type: 'radio' + } + ] + }, + ] + }, + { + title: browser.i18n.getMessage('config_more_caption'), + children: [ + { + title: browser.i18n.getMessage('config_drag_caption'), + enabled: false, + }, + //{ type: 'separator' }, + { + title: indent() + browser.i18n.getMessage('config_tabDragBehavior_caption'), + enabled: false, + }, + // These options are too complex to put in the menu UI, so I simply redirect to the options page. + { + title: indent(2) + browser.i18n.getMessage('config_tabDragBehavior_label') + '...', + url: `${Constants.kSHORTHAND_URIS.options}#tabDragBehaviorConfigsGroup`, + }, + { + title: indent(2) + browser.i18n.getMessage('config_tabDragBehaviorShift_label') + '...', + url: `${Constants.kSHORTHAND_URIS.options}#tabDragBehaviorConfigsGroup`, + }, + { + title: indent(2) + browser.i18n.getMessage('config_showTabDragBehaviorNotification_label'), + key: 'showTabDragBehaviorNotification', + type: 'checkbox', + expert: true, + }, + //{ type: 'separator' }, + { + title: indent() + browser.i18n.getMessage('config_dropLinksOnTabBehavior_caption'), + enabled: false, + expert: true, + }, + { + title: indent(2) + browser.i18n.getMessage('config_dropLinksOnTabBehavior_ask'), + key: 'dropLinksOnTabBehavior', + value: Constants.kDROPLINK_ASK, + type: 'radio', + expert: true, + }, + { + title: indent(2) + browser.i18n.getMessage('config_dropLinksOnTabBehavior_load'), + key: 'dropLinksOnTabBehavior', + value: Constants.kDROPLINK_LOAD, + type: 'radio', + expert: true, + }, + { + title: indent(2) + browser.i18n.getMessage('config_dropLinksOnTabBehavior_newtab'), + key: 'dropLinksOnTabBehavior', + value: Constants.kDROPLINK_NEWTAB, + type: 'radio', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('config_simulateTabsLoadInBackgroundInverted_label'), + type: 'checkbox', + key: 'simulateTabsLoadInBackgroundInverted', + expert: true, + }, + { + title: indent(2) + browser.i18n.getMessage('config_tabsLoadInBackgroundDiscarded_label'), + type: 'checkbox', + key: 'tabsLoadInBackgroundDiscarded', + expert: true, + }, + //{ type: 'separator', expert: true }, + { + title: indent() + browser.i18n.getMessage('config_insertDroppedTabsAt_caption'), + enabled: false, + expert: true, + }, + { + title: indent(2) + browser.i18n.getMessage('config_insertDroppedTabsAt_inherit'), + key: 'insertDroppedTabsAt', + value: Constants.kINSERT_INHERIT, + type: 'radio', + expert: true, + }, + { + title: indent(2) + browser.i18n.getMessage('config_insertDroppedTabsAt_first'), + key: 'insertDroppedTabsAt', + value: Constants.kINSERT_TOP, + type: 'radio', + expert: true, + }, + { + title: indent(2) + browser.i18n.getMessage('config_insertDroppedTabsAt_end'), + key: 'insertDroppedTabsAt', + value: Constants.kINSERT_END, + type: 'radio', + expert: true, + }, + //{ type: 'separator', expert: true }, + { + dynamicTitle: true, + get title() { + return indent() + browser.i18n.getMessage('config_autoExpandOnLongHoverDelay_before') + delimiter + configs.autoExpandOnLongHoverDelay + delimiter + browser.i18n.getMessage('config_autoExpandOnLongHoverDelay_after'); + }, + key: 'autoExpandOnLongHover', + type: 'checkbox', + expert: true, + }, + { + title: indent(2) + browser.i18n.getMessage('config_autoExpandOnLongHoverRestoreIniitalState_label'), + key: 'autoExpandOnLongHoverRestoreIniitalState', + type: 'checkbox', + expert: true, + }, + { + title: indent(2) + browser.i18n.getMessage('config_autoExpandIntelligently_label'), + key: 'autoExpandIntelligently', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('config_ignoreTabDropNearSidebarArea_label'), + key: 'ignoreTabDropNearSidebarArea', + type: 'checkbox', + expert: true, + }, + { type: 'separator' }, + { + title: browser.i18n.getMessage('config_advanced_caption'), + enabled: false + }, + { + title: indent() + browser.i18n.getMessage('config_warnOnCloseTabs_label'), + key: 'warnOnCloseTabs', + type: 'checkbox' + }, + { + title: indent(2) + browser.i18n.getMessage('config_warnOnCloseTabsByClosebox_label'), + key: 'warnOnCloseTabsByClosebox', + type: 'checkbox', + }, + { + title: indent(2) + browser.i18n.getMessage('config_warnOnCloseTabsWithListing_label'), + key: 'warnOnCloseTabsWithListing', + type: 'checkbox', + expert: true + }, + { + title: indent() + browser.i18n.getMessage('config_useCachedTree_label'), + key: 'useCachedTree', + type: 'checkbox', + expert: true, + }, + { + title: indent(2) + browser.i18n.getMessage('config_persistCachedTree_label'), + key: 'persistCachedTree', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('config_tabGroupsEnabled_label'), + key: 'tabGroupsEnabled', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('config_undoMultipleTabsClose_label'), + key: 'undoMultipleTabsClose', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('config_requestPermissions_bookmarks_context'), + type: 'checkbox', + permissions: Permissions.BOOKMARKS + }, + { type: 'separator' }, + { + title: browser.i18n.getMessage('config_debug_caption'), + enabled: false + }, + { + title: indent() + browser.i18n.getMessage('config_colorScheme_caption'), + children: [ + { + title: browser.i18n.getMessage('config_colorScheme_photon'), + key: 'colorScheme', + value: 'photon', + type: 'radio' + }, + { + title: browser.i18n.getMessage('config_colorScheme_systemColor'), + key: 'colorScheme', + value: 'system-color', + type: 'radio' + } + ] + }, + { + title: indent() + browser.i18n.getMessage('config_enableLinuxBehaviors_label'), + key: 'enableLinuxBehaviors', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('config_enableMacOSBehaviors_label'), + key: 'enableMacOSBehaviors', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('config_enableWindowsBehaviors_label'), + key: 'enableWindowsBehaviors', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('config_loggingQueries_label'), + key: 'loggingQueries', + type: 'checkbox', + expert: true, + }, + { + title: indent(2) + browser.i18n.getMessage('config_loggingConnectionMessages_label'), + key: 'loggingConnectionMessages', + type: 'checkbox', + expert: true, + }, + { + title: indent() + browser.i18n.getMessage('config_debug_label'), + key: 'debug', + type: 'checkbox' + }, + { type: 'separator' }, + { + title: browser.i18n.getMessage('config_showExpertOptions_label'), + key: 'showExpertOptions', + type: 'checkbox' + } + ] + } +]; + +const mItemsById = new Map(); +const mUpdatableItemsById = new Map(); +const mExpertItems = new Set(); + +const MENU_CONTEXT = browser.browserAction ? + 'browser_action' : // Manifest V2 + 'action'; // Manifest V3 + +function createItem(id, item, parent) { + if (item.visible === false) + return; + + const parentId = parent ? parent.id : null ; + item.id = id; + mItemsById.set(id, item); + + if (item.dynamicTitle) { + item.lastTitle = item.title; + mUpdatableItemsById.set(id, item); + } + if (item.type == 'checkbox' || item.type == 'radio') { + mUpdatableItemsById.set(id, item); + } + + const params = { + id, + title: item.title?.replace(/^:|:$/g, ''), + type: item.type || 'normal', + contexts: [MENU_CONTEXT], + parentId + }; + if ('enabled' in item) + params.enabled = item.enabled; + log('create: ', params); + id = browser.menus.create(params); + if (item.expert) + mExpertItems.add(id); + if (item.children) { + for (let i = 0, maxi = item.children.length; i < maxi; i++) { + const child = item.children[i]; + createItem(`${id}:${i}`, child, item); + } + } +} + +if (browser.action/* Manifest V2 */ || browser.browserAction/* Manifest V3 */) { + for (let i = 0, maxi = mItems.length; i < maxi; i++) { + createItem(`browserActionItem:${i}`, mItems[i]); + } + + browser.menus.onShown.addListener((info, _tab) => { + if (!info.contexts.includes(MENU_CONTEXT)) + return; + + let updated = false; + for (const item of mUpdatableItemsById.values()) { + const params = {}; + if (item.dynamicTitle) { + const title = item.title; + if (title != item.lastTitle) { + item.lastTitle = title; + params.title = title; + } + } + if (item.type == 'checkbox' || item.type == 'radio') { + const checkedFromConfigs = 'value' in item ? configs[item.key] == item.value : configs[item.key]; + params.checked = checkedFromConfigs; + if (item.permissions) { + Permissions.isGranted(item.permissions) + .then(async granted => { + const checked = granted && (!('key' in item) || checkedFromConfigs); + if (checked == granted && + params.checked == granted) + return; + item.checked = checked; + await browser.menus.update(item.id, { checked }).catch(ApiTabs.createErrorSuppressor()); + await browser.menus.refresh().catch(ApiTabs.createErrorSuppressor()); + }); + delete params.checked; + } + } + if ('visible' in item) + params.visible = item.visible; + if ('checked' in params || 'title' in params) { + browser.menus.update(item.id, params).catch(ApiTabs.createErrorSuppressor()); + updated = true; + } + } + if (updated) + browser.menus.refresh().catch(ApiTabs.createErrorSuppressor()); + }); + + browser.menus.onClicked.addListener((info, _tab) => { + const item = mItemsById.get(info.menuItemId); + log('onClicked ', { id: info.menuItemId, item }); + if (!item) + return; + + if (item.url) { + browser.tabs.create({ url: item.url }); + return; + } + if (item.key) { + if (info.checked) + configs[item.key] = 'value' in item ? item.value : true; + else if (!('value' in item)) + configs[item.key] = false; + } + if (item.permissions) { + if (!info.checked) { + if (item.canRevoke !== false) + browser.permissions.remove(item.permissions).catch(ApiTabs.createErrorSuppressor()); + } + else { + browser.permissions.request(item.permissions) + .then(async granted => { + if (granted === undefined) + granted = await Permissions.isGranted(item.permissions); + if (granted) { + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_PERMISSIONS_GRANTED, + permissions: item.permissions + }).catch(_error => {}); + } + }) + .catch(ApiTabs.createErrorHandler()); + } + } + }); + + + function updateExpertOptionsVisibility() { + for (const id of mExpertItems) { + browser.menus.update(id, { visible: configs.showExpertOptions }); + } + browser.menus.refresh(); + } + configs.$addObserver(key => { + if (key == 'showExpertOptions') + updateExpertOptionsVisibility(); + }); + configs.$loaded.then(updateExpertOptionsVisibility); +} diff --git a/waterfox/browser/components/sidebar/background/commands.js b/waterfox/browser/components/sidebar/background/commands.js new file mode 100644 index 000000000000..f99b9e0a6381 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/commands.js @@ -0,0 +1,1566 @@ +/* +# 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'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; + +import { + log as internalLogger, + dumpTab, + wait, + countMatched, + configs, + getWindowParamsFromSource, + tryRevokeObjectURL, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Bookmark from '/common/bookmark.js'; +import * as Constants from '/common/constants.js'; +import * as SidebarConnection from '/common/sidebar-connection.js'; +import * as TabsInternalOperation from '/common/tabs-internal-operation.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TreeBehavior from '/common/tree-behavior.js'; +import * as TSTAPI from '/common/tst-api.js'; +import * as UserOperationBlocker from '/common/user-operation-blocker.js'; + +import { Tab, TabGroup, TreeItem } from '/common/TreeItem.js'; + +import * as NativeTabGroups from './native-tab-groups.js'; +import * as TabsGroup from './tabs-group.js'; +import * as TabsMove from './tabs-move.js'; +import * as TabsOpen from './tabs-open.js'; +import * as Tree from './tree.js'; + +function log(...args) { + internalLogger('background/commands', ...args); +} + +export const onTabsClosing = new EventListenerManager(); +export const onMoveUp = new EventListenerManager(); +export const onMoveDown = new EventListenerManager(); + +export function reloadTree(tabs) { + for (const tab of Tab.uniqTabsAndDescendantsSet(tabs)) { + browser.tabs.reload(tab.id) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } +} + +export function reloadDescendants(rootTabs) { + const rootTabsSet = new Set(rootTabs); + for (const tab of Tab.uniqTabsAndDescendantsSet(rootTabs)) { + if (rootTabsSet.has(tab)) + continue; + browser.tabs.reload(tab.id) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } +} + +function isUnmuted(tab) { + return !tab.mutedInfo || !tab.mutedInfo.muted; +} + +export function toggleMuteTree(tabs) { + const tabsToUpdate = []; + let shouldMute = false; + for (const tab of Tab.uniqTabsAndDescendantsSet(tabs)) { + if (!shouldMute && isUnmuted(tab)) + shouldMute = true; + tabsToUpdate.push(tab); + } + for (const tab of tabsToUpdate) { + if (shouldMute != isUnmuted(tab)) + continue; + browser.tabs.update(tab.id, { muted: shouldMute }) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } +} + +export function toggleMuteDescendants(rootTabs) { + const rootTabsSet = new Set(rootTabs); + const tabsToUpdate = []; + let shouldMute = false; + for (const tab of Tab.uniqTabsAndDescendantsSet(rootTabs)) { + if (rootTabsSet.has(tab)) + continue; + if (!shouldMute && isUnmuted(tab)) + shouldMute = true; + tabsToUpdate.push(tab); + } + for (const tab of tabsToUpdate) { + if (shouldMute != isUnmuted(tab)) + continue; + browser.tabs.update(tab.id, { muted: shouldMute }) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } +} + +export function getUnmutedState(rootTabs) { + let hasUnmutedTab = false; + let hasUnmutedDescendant = false; + const rootTabsSet = new Set(rootTabs); + for (const tab of Tab.uniqTabsAndDescendantsSet(rootTabs)) { + if (!isUnmuted(tab)) + continue; + hasUnmutedTab = true; + if (!rootTabsSet.has(tab)) + hasUnmutedDescendant = true; + if (hasUnmutedTab && hasUnmutedDescendant) + break; + } + return { hasUnmutedTab, hasUnmutedDescendant }; +} + +export function getAutoplayBlockedState(rootTabs) { + let hasAutoplayBlockedTab = false; + let hasAutoplayBlockedDescendant = false; + const rootTabsSet = new Set(rootTabs); + for (const tab of Tab.uniqTabsAndDescendantsSet(rootTabs)) { + if (!tab.$TST.autoplayBlocked) + continue; + hasAutoplayBlockedTab = true; + if (!rootTabsSet.has(tab)) + hasAutoplayBlockedDescendant = true; + if (hasAutoplayBlockedTab && hasAutoplayBlockedDescendant) + break; + } + return { hasAutoplayBlockedTab, hasAutoplayBlockedDescendant }; +} + +export function getMenuItemTitle(item, { multiselected, unmuted, hasUnmutedTab, hasUnmutedDescendant, sticky } = {}) { + const muteTabSuffix = unmuted ? 'Mute' : 'Unmute'; + const muteTreeSuffix = hasUnmutedTab ? 'Mute' : 'Unmute'; + const muteDescendantSuffix = hasUnmutedDescendant ? 'Mute' : 'Unmute'; + const stickySuffix = sticky ? 'Unstick' : 'Stick'; + return multiselected && ( + item[`titleMultiselected${muteTabSuffix}`] || + item[`titleMultiselected${muteTreeSuffix}Tree`] || + item[`titleMultiselected${muteDescendantSuffix}Descendant`] || + item[`titleMultiselected${stickySuffix}`] || + item.titleMultiselected + ) || ( + item[`title${muteTabSuffix}`] || + item[`title${muteTreeSuffix}Tree`] || + item[`title${muteDescendantSuffix}Descendant`] || + item[`title${stickySuffix}`] || + item.title + ); +} + +export async function closeTree(tabs) { + tabs = Tab.uniqTabsAndDescendantsSet(tabs); + const windowId = tabs[0].windowId; + const canceled = (await onTabsClosing.dispatch(tabs.map(tab => tab.id), { windowId })) === false; + if (canceled) + return; + tabs.reverse(); // close bottom to top! + TabsInternalOperation.removeTabs(tabs); +} + +export async function closeDescendants(rootTabs) { + const rootTabsSet = new Set(rootTabs); + const tabs = Tab.uniqTabsAndDescendantsSet(rootTabs).filter(tab => !rootTabsSet.has(tab)); + const windowId = rootTabs[0].windowId; + const canceled = (await onTabsClosing.dispatch(tabs.map(tab => tab.id), { windowId })) === false; + if (canceled) + return; + tabs.reverse(); // close bottom to top! + TabsInternalOperation.removeTabs(tabs); +} + +export async function closeOthers(exceptionRoots) { + const exceptionTabs = Tab.uniqTabsAndDescendantsSet(exceptionRoots); + const windowId = exceptionTabs[0].windowId; + const tabs = Tab.getNormalTabs(windowId, { iterator: true, reversed: true }); // except pinned or hidden tabs, close bottom to top! + const closeTabs = []; + for (const tab of tabs) { + if (!exceptionTabs.includes(tab)) + closeTabs.push(tab); + } + const canceled = (await onTabsClosing.dispatch(closeTabs.map(tab => tab.id), { windowId })) === false; + if (canceled) + return; + TabsInternalOperation.removeTabs(closeTabs); +} + +export function collapseTree(rootTabs, { recursively } = {}) { + rootTabs = Array.isArray(rootTabs) && rootTabs || [rootTabs]; + const rootTabsSet = new Set(rootTabs); + const tabs = ( + recursively ? + Tab.uniqTabsAndDescendantsSet(rootTabs) : + rootTabs + ).filter(tab => tab.$TST.hasChild && !tab.$TST.subtreeCollapsed); + const cache = {}; + for (const tab of tabs) { + TSTAPI.tryOperationAllowed( + TSTAPI.kNOTIFY_TRY_COLLAPSE_TREE_FROM_COLLAPSE_COMMAND, + { + tab, + recursivelyCollapsed: !rootTabsSet.has(tab), + }, + { tabProperties: ['tab'], cache } + ).then(allowed => { + if (!allowed) + return; + Tree.collapseExpandSubtree(tab, { + collapsed: true, + broadcast: true + }); + }); + } + TSTAPI.clearCache(cache); +} + +export function collapseAll(windowId) { + const cache = {}; + for (const tab of Tab.getNormalTabs(windowId, { iterator: true })) { + if (!tab.$TST.hasChild || tab.$TST.subtreeCollapsed) + continue; + TSTAPI.tryOperationAllowed( + TSTAPI.kNOTIFY_TRY_COLLAPSE_TREE_FROM_COLLAPSE_ALL_COMMAND, + { tab }, + { tabProperties: ['tab'], cache } + ).then(allowed => { + if (!allowed) + return; + Tree.collapseExpandSubtree(tab, { + collapsed: true, + broadcast: true, + }); + }); + } + TSTAPI.clearCache(cache); +} + +export function expandTree(rootTabs, { recursively } = {}) { + rootTabs = Array.isArray(rootTabs) && rootTabs || [rootTabs]; + const rootTabsSet = new Set(rootTabs); + const tabs = ( + recursively ? + Tab.uniqTabsAndDescendantsSet(rootTabs) : + rootTabs + ).filter(tab => tab.$TST.hasChild && tab.$TST.subtreeCollapsed); + const cache = {}; + for (const tab of tabs) { + TSTAPI.tryOperationAllowed( + TSTAPI.kNOTIFY_TRY_EXPAND_TREE_FROM_EXPAND_COMMAND, + { + tab, + recursivelyExpanded: !rootTabsSet.has(tab), + }, + { tabProperties: ['tab'], cache } + ).then(allowed => { + if (!allowed) + return; + Tree.collapseExpandSubtree(tab, { + collapsed: false, + broadcast: true, + }); + }); + } + TSTAPI.clearCache(cache); +} + +export function expandAll(windowId) { + const cache = {}; + for (const tab of Tab.getNormalTabs(windowId, { iterator: true })) { + if (!tab.$TST.hasChild || !tab.$TST.subtreeCollapsed) + continue; + TSTAPI.tryOperationAllowed( + TSTAPI.kNOTIFY_TRY_EXPAND_TREE_FROM_EXPAND_ALL_COMMAND, + { tab }, + { tabProperties: ['tab'], cache } + ).then(allowed => { + if (!allowed) + return; + Tree.collapseExpandSubtree(tab, { + collapsed: false, + broadcast: true, + }); + }); + } + TSTAPI.clearCache(cache); +} + +export async function bookmarkTree(rootTabs, { parentId, index, showDialog } = {}) { + const tabs = Tab.uniqTabsAndDescendantsSet(rootTabs); + + if (tabs.length > 1) { + const tabsSet = new Set(tabs); + const rootGroupTabs = tabs.filter(tab => tab.$TST.isGroupTab && !tabsSet.has(tab.$TST.parent)); + if (rootGroupTabs.length == 1) + tabs.splice(tabs.indexOf(rootGroupTabs[0]), 1); + } + + const options = { parentId, index, showDialog }; + const topLevelTabs = rootTabs.filter(tab => tab.$TST.ancestorIds.length == 0); + if (topLevelTabs.length == 1 && + topLevelTabs[0].$TST.isGroupTab) + options.title = topLevelTabs[0].title; + + const tab = tabs[0]; + if (configs.showDialogInSidebar && + SidebarConnection.isOpen(tab.windowId)) { + return SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_BOOKMARK_TABS_WITH_DIALOG, + windowId: tab.windowId, + tabIds: tabs.map(tab => tab.id), + options + }); + } + else { + return Bookmark.bookmarkTabs(tabs, { + ...options, + showDialog: true + }); + } +} + +export function toggleSticky(tabs, shouldStick = undefined) { + const uniqueTabs = [...new Set(tabs)]; + if (shouldStick === undefined) + shouldStick = uniqueTabs.some(tab => !tab.$TST.sticky); + for (const tab of uniqueTabs) { + tab.$TST.toggleState(Constants.kTAB_STATE_STICKY, !!shouldStick, { permanently: true }); + } +} + + +export async function openNewTabAs(options = {}) { + log('openNewTabAs ', options); + let activeTabs; + if (!options.baseTab) { + activeTabs = await browser.tabs.query({ + active: true, + currentWindow: true, + }).catch(ApiTabs.createErrorHandler()); + if (activeTabs.length == 0) + activeTabs = await browser.tabs.query({ + currentWindow: true, + }).catch(ApiTabs.createErrorHandler()); + } + const activeTab = options.baseTab || Tab.get(activeTabs[0].id); + log('activeTab ', activeTab); + + let parent, insertBefore, insertAfter; + let isOrphan = false; + switch (options.as) { + case Constants.kNEWTAB_DO_NOTHING: + default: + break; + + case Constants.kNEWTAB_OPEN_AS_ORPHAN: + isOrphan = true; + insertAfter = Tab.getLastTab(activeTab.windowId); + break; + + case Constants.kNEWTAB_OPEN_AS_CHILD: + case Constants.kNEWTAB_OPEN_AS_CHILD_TOP: + case Constants.kNEWTAB_OPEN_AS_CHILD_END: { + parent = activeTab; + const insertAt = options.as == Constants.kNEWTAB_OPEN_AS_CHILD_TOP ? + Constants.kINSERT_TOP : + options.as == Constants.kNEWTAB_OPEN_AS_CHILD_END ? + Constants.kINSERT_END : + undefined; + const refTabs = Tree.getReferenceTabsForNewChild(null, parent, { insertAt }); + insertBefore = refTabs.insertBefore; + insertAfter = refTabs.insertAfter; + log('detected reference tabs: ', + { parent, insertBefore, insertAfter }); + }; break; + + case Constants.kNEWTAB_OPEN_AS_SIBLING: + parent = activeTab.$TST.parent; + insertAfter = parent?.$TST.lastDescendant; + break; + + case Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING_WITH_INHERITED_CONTAINER: + options.cookieStoreId = activeTab.cookieStoreId; + case Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING: { + parent = activeTab.$TST.parent; + const refTabs = Tree.getReferenceTabsForNewNextSibling(activeTab, options); + insertBefore = refTabs.insertBefore; + insertAfter = refTabs.insertAfter; + }; break; + } + + log('options.cookieStoreId: ', options.cookieStoreId); + if (!options.cookieStoreId) { + switch (configs.inheritContextualIdentityToChildTabMode) { + case Constants.kCONTEXTUAL_IDENTITY_FROM_PARENT: + if (parent) { + options.cookieStoreId = parent.cookieStoreId; + log(' => inherit from parent tab: ', options.cookieStoreId); + } + break; + + case Constants.kCONTEXTUAL_IDENTITY_FROM_LAST_ACTIVE: + options.cookieStoreId = activeTab.cookieStoreId; + log(' => inherit from active tab: ', options.cookieStoreId); + break; + + default: + break; + } + } + + TabsOpen.openNewTab({ + parent, insertBefore, insertAfter, + isOrphan, + windowId: activeTab.windowId, + inBackground: !!options.inBackground, + cookieStoreId: options.cookieStoreId, + url: options.url, + }); +} + +export async function indent(tab, options = {}) { + const newParent = tab.$TST.previousSiblingTab; + if (!newParent || + newParent == tab.$TST.parent) + return false; + + if (!options.followChildren) + Tree.detachAllChildren(tab, { + broadcast: true, + behavior: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD + }); + const insertAfter = newParent.$TST.lastDescendant || newParent; + await Tree.attachTabTo(tab, newParent, { + broadcast: true, + forceExpand: true, + insertAfter + }); + return true; +} + +export async function outdent(tab, options = {}) { + const parent = tab.$TST.parent; + if (!parent) + return false; + + const newParent = parent.$TST.parent; + if (!options.followChildren) + Tree.detachAllChildren(tab, { + broadcast: true, + behavior: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD + }); + if (newParent) { + const insertAfter = parent.$TST.lastDescendant || parent; + await Tree.attachTabTo(tab, newParent, { + broadcast: true, + forceExpand: true, + insertAfter + }); + } + else { + await Tree.detachTab(tab, { + broadcast: true, + }); + const insertAfter = parent.$TST.lastDescendant || parent; + await Tree.moveTabSubtreeAfter(tab, insertAfter, { + broadcast: true, + }); + } + return true; +} + +// drag and drop helper +async function performTreeItemsDragDrop(params = {}) { + const windowId = params.windowId || TabsStore.getCurrentWindowId(); + const destinationWindowId = params.destinationWindowId || windowId; + + switch (params.items[0].type) { + case 'group': + return performNativeTabGroupItemDragDrop(params.items[0], { + windowId, + destinationWindowId, + ...params, + }); + + case 'tab': + default: + return performTabsDragDrop(params.items, { + windowId, + destinationWindowId, + ...params, + }); + } +} + +async function performTreeItemsDragDropWithMessage(message) { + const draggedTabIds = message.import ? [] : message.items.map(item => item.type == TreeItem.TYPE_TAB && item.id || null); + await Tab.waitUntilTracked(draggedTabIds.concat([ + message.droppedOn?.type == TreeItem.TYPE_TAB && message.droppedOn.id, + message.droppedBefore?.type == TreeItem.TYPE_TAB && message.droppedBefore.id, + message.droppedAfter?.type == TreeItem.TYPE_TAB && message.droppedAfter.id, + message.attachToId, + message.insertBefore?.type == TreeItem.TYPE_TAB && message.insertBefore.id, + message.insertAfter?.type == TreeItem.TYPE_TAB && message.insertAfter.id, + ])); + log('perform tabs dragdrop requested: ', message); + return performTreeItemsDragDrop({ + ...message, + items: message.import ? message.items : message.items.map(TreeItem.get), + droppedOn: TreeItem.get(message.droppedOn), + droppedBefore: TreeItem.get(message.droppedBefore), + droppedAfter: TreeItem.get(message.droppedAfter), + attachTo: Tab.get(message.attachToId), + insertBefore: TreeItem.get(message.insertBefore), + insertAfter: TreeItem.get(message.insertAfter), + }); +} + +async function performTabsDragDrop(tabs, params) { + log('performTabsDragDrop ', () => ({ + tabs: tabs.map(tab => `${tab.groupId}/#${tab.id}`), + droppedOn: dumpTab(params.droppedOn), + droppedBefore: dumpTab(params.droppedBefore), + droppedAfter: dumpTab(params.droppedAfter), + groupId: params.groupId, + attachTo: dumpTab(params.attachTo), + insertBefore: dumpTab(params.insertBefore), + insertAfter: dumpTab(params.insertAfter), + nextGroupColor: params.nextGroupColor, + canCreateGroup: params.canCreateGroup, + windowId: params.windowId, + destinationWindowId: params.destinationWindowId, + action: params.action, + allowedActions: params.allowedActions + })); + + const createGroup = params.canCreateGroup && params.nextGroupColor; + const beforeIndices = tabs.map(tab => tab.index).join(','); + + if (!(params.allowedActions & Constants.kDRAG_BEHAVIOR_MOVE) && + !params.duplicate) { + log(' => not allowed action'); + return tabs; + } + + const nativeTabGroupIdFromPositionDeterminedByBrowser = (params.insertAfter && params.insertAfter.groupId != -1) ? + params.insertAfter.groupId : + params.insertBefore ? + params.insertBefore.groupId : + -1; + const nativeTabGroupId = params.groupId || ( + params.attachTo ? params.attachTo.groupId : + nativeTabGroupIdFromPositionDeterminedByBrowser + ); + const draggedGroupParams = nativeTabGroupId == tabs[0].groupId ? + tabs[0]?.$TST?.nativeTabGroup?.$TST?.createParams : + null; + + log('performTabsDragDrop: nativeTabGroupId = ', nativeTabGroupId, ', nativeTabGroupIdFromPositionDeterminedByBrowser = ', nativeTabGroupIdFromPositionDeterminedByBrowser, ', draggedGroupParams = ', draggedGroupParams, ', createGroup = ', createGroup); + + let blockedWindowId = null; + if ((params.groupId && + tabs[0].groupId != params.groupId) || + (tabs[0].groupId != (params.droppedOn || params.droppedBefore || params.droppedAfter)?.groupId)) { + blockedWindowId = tabs[0].windowId; + UserOperationBlocker.blockIn(blockedWindowId, { throbber: true }); + } + + if ((params.droppedOn?.type == TreeItem.TYPE_GROUP || + params.droppedAfter?.type == TreeItem.TYPE_GROUP || + params.droppedBefore?.type == TreeItem.TYPE_GROUP) && + tabs.some(tab => tab.groupId != -1 && tab.groupId != nativeTabGroupId)) { + log('performTabsDragDrop: remove from group'); + await NativeTabGroups.removeTabsFromGroup(tabs); + } + + const { windowId, destinationWindowId } = params; + const isAcrossWindows = windowId != destinationWindowId; + + if (!isAcrossWindows) { + // On in-window tab move, we need to apply final group at first. + // Otherwise tab move after group modifications may break groups. + await NativeTabGroups.matchTabsGrouped(tabs, nativeTabGroupId); + } + + const movedTabs = await moveTabsWithStructure(tabs, { + ...params, + ...(createGroup ? { attachTo: null } : {}), + windowId, + destinationWindowId, + // TST automatically optimize rearrangement of tabs, but we need to disable it here to avoid unexpected group modifications by moved other tabs. + doNotOptimize: TabsStore.windows.get(destinationWindowId).tabGroups.size > 0 || nativeTabGroupId != -1, + broadcast: true + }); + log('performTabsDragDrop: movedTabs = ', movedTabs, { isAcrossWindows }); + + if (isAcrossWindows) { + // On tab move across windows, we need to apply final group after + // tabs are moved to the destination window. + await NativeTabGroups.matchTabsGrouped(tabs, draggedGroupParams || nativeTabGroupId); + } + + if (createGroup) { + await NativeTabGroups.addTabsToGroup([params.droppedOn, ...tabs], { + title: '', + color: params.nextGroupColor, + windowId: params.destinationWindowId, + }); + } + else if (nativeTabGroupId != -1 && + !isAcrossWindows) { + await NativeTabGroups.rejectGroupFromTree(TabGroup.get(nativeTabGroupId)) + } + + const afterIndices = movedTabs.map(tab => tab.index).join(','); + if (beforeIndices != afterIndices /* Firefox's automatic group maintenance won't happen if tabs were not moved */ && + nativeTabGroupId != nativeTabGroupIdFromPositionDeterminedByBrowser) { + // Automatic group maintenance done by Firefox based on tabs' destination position + // may change groups. We need to override the result with the given group id. + log('performTabsDragDrop: final group id = ', nativeTabGroupId); + let onGroupModified; + const toBeModifiedTabs = new Set(movedTabs.map(tab => tab.id)); + const startAt = Date.now(); + await Promise.race([ + new Promise((resolve, _reject) => { + onGroupModified = (tabId, updateInfo, _tab) => { + if (updateInfo.groupId != nativeTabGroupId) { + log(`performTabsDragDrop: tab group modifications detected (${updateInfo.groupId}) with delay ${Date.now() - startAt} msec.`); + toBeModifiedTabs.delete(tabId); + } + if (toBeModifiedTabs.size == 0) { + log('performTabsDragDrop: all members have been updated'); + resolve(); + } + }; + browser.tabs.onUpdated.addListener(onGroupModified, { + properties: ['groupId'], + windowId: destinationWindowId, + }); + }), + wait(configs.nativeTabGroupModificationDetectionTimeoutAfterTabMove).then(() => { + log('performTabsDragDrop: tab group modifications detection timeout'); + }), + ]); + browser.tabs.onUpdated.removeListener(onGroupModified); + log('performTabsDragDrop: match group with ', draggedGroupParams || nativeTabGroupId); + await NativeTabGroups.matchTabsGrouped(movedTabs, draggedGroupParams || nativeTabGroupId); + } + + if (blockedWindowId) { + UserOperationBlocker.unblockIn(blockedWindowId, { throbber: true }); + } + + log('performTabsDragDrop: finish'); + + if (movedTabs.length == 0) + return movedTabs; + + if (windowId != destinationWindowId) { + // Firefox always focuses to the dropped (mvoed) tab if it is dragged from another window. + // TST respects Firefox's the behavior. + await browser.tabs.update(movedTabs[0].id, { active: true }) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } + return movedTabs; +} + +async function performNativeTabGroupItemDragDrop(group, { droppedOn, droppedBefore, droppedAfter, groupId, attachTo, windowId, destinationWindowId }) { + log('performNativeTabGroupItemDragDrop ', () => ({ group, groupId, droppedOn, droppedBefore, droppedAfter, attachTo, windowId, destinationWindowId })); + + if (droppedOn?.type == TreeItem.TYPE_GROUP) { + log('performNativeTabGroupItemDragDrop: dropping onto another group, merge to it: ', droppedOn); + const members = group.$TST.members; + const firstMember = droppedOn.$TST.firstMember; + if (group.$TST.firstMember.index < firstMember.index) { + await NativeTabGroups.moveGroupBefore(group, firstMember); + } + else{ + await NativeTabGroups.moveGroupAfter(group, droppedOn.$TST.lastMember); + } + await NativeTabGroups.addTabsToGroup(members, droppedOn.id); + return; + } + + let insertAfter = droppedAfter; + let insertBefore = droppedBefore; + const firstMember = group.$TST.firstMember; + + if (groupId) { + if (groupId != group.id) { + const dropTargetGroup = TabGroup.get(groupId); + const dropTargetFirstMember = dropTargetGroup.$TST.firstMember; + if (firstMember.index < dropTargetFirstMember.index) { + log('performNativeTabGroupItemDragDrop: dropping into another group, move to below the target group'); + insertAfter = dropTargetGroup.$TST.lastMember; + if (insertAfter) { + insertBefore = null; + } + } + else { + log('performNativeTabGroupItemDragDrop: dropping into another group, move to above the target group'); + insertBefore = dropTargetFirstMember; + if (insertBefore) { + insertAfter = null; + } + } + } + else if (droppedOn || + droppedBefore?.$TST.parent || + (droppedAfter?.$TST.parent && + droppedAfter.$TST.rootTab == droppedAfter.$TST.unsafeNextTab?.$TST.rootTab)) { + const root = (droppedOn || droppedBefore || droppedAfter).$TST.rootTab; + if (root) { + if (firstMember.index < root.index) { + log('performNativeTabGroupItemDragDrop: dropping into ungrouped tree, move to below the target tree'); + insertAfter = root.$TST.lastDescendant || root; + if (insertAfter) { + insertBefore = null; + } + } + else { + log('performNativeTabGroupItemDragDrop: dropping into ungrouped tree, move to above the target tree'); + insertBefore = root; + if (insertBefore) { + insertAfter = null; + } + } + } + } + } + + const { promisedMoved, finish } = NativeTabGroups.waitUntilMoved(group, destinationWindowId); + + if (insertAfter) { + if (insertAfter.groupId == -1) { + insertAfter = insertAfter.$TST.rootTab.$TST.lastDescendant || insertAfter; + } + log('performNativeTabGroupItemDragDrop: move the group below the specified tab ', insertAfter.id); + await NativeTabGroups.moveGroupAfter(group, insertAfter); + } + else if (insertBefore) { + if (insertBefore.groupId == -1) { + insertBefore = insertBefore.$TST.rootTab; + } + log('performNativeTabGroupItemDragDrop: move the group above the specified tab ', insertBefore.id); + await NativeTabGroups.moveGroupBefore(group, insertBefore); + } + else { + finish(); + throw new Error('performNativeTabGroupItemDragDrop: no hint to move specified group'); + } + + await Promise.race([ + promisedMoved, + wait(configs.nativeTabGroupModificationDetectionTimeoutAfterTabMove).then(() => { + if (finish.done) { + return; + } + log('performNativeTabGroupItemDragDrop: tab group modifications detection timeout'); + }), + ]); +} + +// useful utility for general purpose +export async function moveTabsWithStructure(tabs, params = {}) { + log('moveTabsWithStructure ', () => tabs.map(dumpTab)); + + let movedTabs = tabs.filter(tab => !!tab); + if (!movedTabs.length) + return []; + + let movedRoots = params.import ? [] : Tab.collectRootTabs(movedTabs); + + const movedWholeTree = Tree.getWholeTree(movedRoots); + log('=> movedTabs: ', () => ['moved', movedTabs.map(dumpTab).join(' / '), 'whole', movedWholeTree.map(dumpTab).join(' / ')]); + + const movedTabsSet = new Set(movedTabs); + while (movedTabsSet.has(params.insertBefore)) { + params.insertBefore = params.insertBefore?.$TST.nextTab; + } + while (movedTabsSet.has(params.insertAfter)) { + params.insertAfter = params.insertAfter?.$TST.previousTab; + } + + const windowId = params.windowId || tabs[0].windowId; + const destinationWindowId = params.destinationWindowId || + params.insertBefore?.windowId || + params.insertAfter?.windowId || + windowId; + + // Basically tabs should not be moved between regular window and private browsing window, + // so there are some codes to prevent shch operations. This is for failsafe. + if (movedTabs[0].incognito != Tab.getFirstTab(destinationWindowId).incognito) + return []; + + if (!params.import && + movedWholeTree.length != movedTabs.length) { + log('=> partially moved'); + if (!params.duplicate) + await Tree.detachTabsFromTree(movedTabs, { + insertBefore: params.insertBefore, + insertAfter: params.insertAfter, + partial: true, + broadcast: params.broadcast, + }); + } + + if (params.import) { + const win = TabsStore.windows.get(destinationWindowId); + const initialIndex = params.insertBefore ? params.insertBefore.index : + params.insertAfter ? params.insertAfter.index+1 : + win.tabs.size; + win.toBeOpenedOrphanTabs += tabs.length; + movedTabs = []; + let index = 0; + for (const tab of tabs) { + let importedTab; + const createParams = { + url: tab.url, + windowId: destinationWindowId, + index: initialIndex + index, + active: index == 0 + }; + try { + importedTab = await browser.tabs.create(createParams); + } + catch(error) { + console.log(error); + } + if (!importedTab) + importedTab = await browser.tabs.create({ + ...createParams, + url: `about:blank?${tab.url}` + }); + movedTabs.push(importedTab); + index++; + } + await wait(100); // wait for all imported tabs are tracked + movedTabs = movedTabs.map(tab => Tab.get(tab.id)); + await Tree.applyTreeStructureToTabs(movedTabs, params.structure, { + broadcast: true + }); + movedRoots = Tab.collectRootTabs(movedTabs); + for (const tab of movedTabs) { + tryRevokeObjectURL(tab.url); + } + } + else if (params.duplicate || + windowId != destinationWindowId) { + movedTabs = await Tree.moveTabs(movedTabs, { + destinationWindowId, + duplicate: params.duplicate, + insertBefore: params.insertBefore, + insertAfter: params.insertAfter, + broadcast: params.broadcast, + }); + movedRoots = Tab.collectRootTabs(movedTabs); + } + + log('try attach/detach'); + let shouldExpand = false; + if (!params.attachTo) { + log('=> detach'); + detachTabsWithStructure(movedRoots, { + broadcast: params.broadcast + }); + shouldExpand = true; + } + else { + log('=> attach'); + await attachTabsWithStructure(movedRoots, params.attachTo, { + insertBefore: params.insertBefore, + insertAfter: params.insertAfter, + draggedTabs: movedTabs, + broadcast: params.broadcast + }); + shouldExpand = !params.attachTo.$TST.subtreeCollapsed; + } + + log('=> moving tabs ', () => movedTabs.map(dumpTab)); + if (params.insertBefore) + await TabsMove.moveTabsBefore( + movedTabs, + params.insertBefore, + { + doNotOptimize: !!params.doNotOptimize, + broadcast: !!params.broadcast, + } + ); + else if (params.insertAfter) + await TabsMove.moveTabsAfter( + movedTabs, + params.insertAfter, + { + doNotOptimize: !!params.doNotOptimize, + broadcast: !!params.broadcast, + } + ); + else + log('=> already placed at expected position'); + + /* + const treeStructure = getTreeStructureFromTabs(movedTabs); + + const newTabs; + const replacedGroupTabs = Tab.doAndGetNewTabs(() => { + newTabs = moveTabsInternal(movedTabs, { + duplicate: params.duplicate, + insertBefore: params.insertBefore, + insertAfter: params.insertAfter + }); + }, windowId); + log('=> opened group tabs: ', replacedGroupTabs); + params.draggedTab.ownerDocument.defaultView.setTimeout(() => { + if (!TabsStore.ensureLivingItem(tab)) // it was removed while waiting + return; + log('closing needless group tabs'); + replacedGroupTabs.reverse().forEach(function(tab) { + log(' check: ', tab.label+'('+tab.index+') '+getLoadingURI(tab)); + if (tab.$TST.isGroupTab && + !tab.$TST.hasChild) + removeTab(tab); + }, this); + }, 0); + */ + + if (shouldExpand) { + log('=> expand dropped tabs'); + // Collapsed tabs may be moved to the root level, + // then we need to expand them. + await Promise.all(movedRoots.map(tab => { + if (!tab.$TST.collapsed) + return; + return Tree.collapseExpandTabAndSubtree(tab, { + collapsed: false, + broadcast: params.broadcast + }); + })); + } + + log('=> finished'); + + return movedTabs; +} + +async function attachTabsWithStructure(tabs, parent, options = {}) { + log('attachTabsWithStructure: start ', () => ['tabs', ...tabs.map(dumpTab), 'parent', dumpTab(parent), 'insertBefore', dumpTab(options.insertBefore), 'insertAfter', dumpTab(options.insertAfter)]); + if (parent && + !options.insertBefore && + !options.insertAfter) { + const refTabs = Tree.getReferenceTabsForNewChild( + tabs[0], + parent, + { ignoreTabs: tabs } + ); + options.insertBefore = refTabs.insertBefore; + options.insertAfter = refTabs.insertAfter; + } + + if (options.insertBefore) + await TabsMove.moveTabsBefore( + options.draggedTabs || tabs, + options.insertBefore, + { broadcast: options.broadcast } + ); + else if (options.insertAfter) + await TabsMove.moveTabsAfter( + options.draggedTabs || tabs, + options.insertAfter, + { broadcast: options.broadcast } + ); + + const memberOptions = { + ...options, + insertBefore: null, + insertAfter: null, + dontMove: true, + forceExpand: options.draggedTabs.some(tab => tab.active) + }; + return Promise.all(tabs.map(async tab => { + if (parent) + await Tree.attachTabTo(tab, parent, memberOptions); + else + await Tree.detachTab(tab, memberOptions); + // The tree can remain being collapsed by other addons like TST Lock Tree Collapsed. + const collapsed = parent?.$TST.subtreeCollapsed; + return Tree.collapseExpandTabAndSubtree(tab, { + ...memberOptions, + collapsed, + }); + })); +} + +function detachTabsWithStructure(tabs, options = {}) { + log('detachTabsWithStructure: start ', () => tabs.map(dumpTab)); + for (const tab of tabs) { + Tree.detachTab(tab, options); + Tree.collapseExpandTabAndSubtree(tab, { + ...options, + collapsed: false + }); + } +} + +export async function moveUp(tab, options = {}) { + const previousTab = tab.$TST.nearestVisiblePrecedingTab; + if (!previousTab) + return false; + const moved = await moveBefore(tab, { + ...options, + referenceTabId: previousTab.id + }); + if (moved && !options.followChildren) + await onMoveUp.dispatch(tab); + return moved; +} + +export async function moveDown(tab, options = {}) { + const nextTab = options.followChildren ? tab.$TST.nearestFollowingForeignerTab : tab.$TST.nearestVisibleFollowingTab; + if (!nextTab) + return false; + const moved = await moveAfter(tab, { + ...options, + referenceTabId: nextTab.id + }); + if (moved && !options.followChildren) + await onMoveDown.dispatch(tab); + return moved; +} + +export async function moveBefore(tab, options = {}) { + const insertBefore = Tab.get(options.referenceTabId || options.referenceTab) || null; + if (!insertBefore) + return false; + + if (!options.followChildren) { + Tree.detachAllChildren(tab, { + broadcast: true, + behavior: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD + }); + await TabsMove.moveTabBefore( + tab, + insertBefore, + { broadcast: true } + ); + } + else { + const referenceTabs = TreeBehavior.calculateReferenceItemsFromInsertionPosition(tab, { + context: Constants.kINSERTION_CONTEXT_MOVED, + insertBefore + }); + if (!referenceTabs.insertBefore && + !referenceTabs.insertAfter) + return false; + await moveTabsWithStructure([tab].concat(tab.$TST.descendants), { + attachTo: referenceTabs.parent, + insertBefore: referenceTabs.insertBefore, + insertAfter: referenceTabs.insertAfter, + broadcast: true + }); + } + return true; +} + +export async function moveAfter(tab, options = {}) { + const insertAfter = Tab.get(options.referenceTabId || options.referenceTab) || null; + if (!insertAfter) + return false; + + if (!options.followChildren) { + Tree.detachAllChildren(tab, { + broadcast: true, + behavior: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD + }); + await TabsMove.moveTabAfter( + tab, + insertAfter, + { broadcast: true } + ); + } + else { + const referenceTabs = TreeBehavior.calculateReferenceItemsFromInsertionPosition(tab, { + context: Constants.kINSERTION_CONTEXT_MOVED, + insertAfter + }); + if (!referenceTabs.insertBefore && !referenceTabs.insertAfter) + return false; + await moveTabsWithStructure([tab].concat(tab.$TST.descendants), { + attachTo: referenceTabs.parent, + insertBefore: referenceTabs.insertBefore, + insertAfter: referenceTabs.insertAfter, + broadcast: true + }); + } + return true; +} + + +/* commands to simulate Firefox's native tab cocntext menu */ + +export async function unloadTabs(tabs) { + tabs = filterUnloadableTabs(tabs); + if (tabs.length == 0) { + return; + } + if (tabs.some(tab => tab.active)) { + await TabsInternalOperation.blurTab(tabs, { keepDiscarded: true }); + } + return browser.tabs.discard(tabs.map(tab => tab.id)); +} + +// Only remote browsers can be unloadable. +// See also: +// https://searchfox.org/mozilla-central/rev/b7b6aa5e8ffc27bc70d4c129c95adc5921766b93/browser/components/tabbrowser/content/tabbrowser.js#1983 +// https://searchfox.org/mozilla-central/rev/b7b6aa5e8ffc27bc70d4c129c95adc5921766b93/toolkit/modules/E10SUtils.sys.mjs#394 +export function filterUnloadableTabs(tabs) { + return tabs.filter(tab => !/^(about|chrome):/i.test(tab.url)); +} + +export async function duplicateTab(sourceTab, options = {}) { + /* + Due to difference between Firefox's "duplicate tab" implementation, + TST sometimes fails to detect duplicated tabs based on its + session information. Thus we need to duplicate as an internally + duplicated tab. For more details, see also: + https://github.com/piroor/treestyletab/issues/1437#issuecomment-334952194 + */ + const isMultiselected = options.multiselected === false ? false : sourceTab.$TST.multiselected; + const sourceTabs = isMultiselected ? Tab.getSelectedTabs(sourceTab.windowId) : [sourceTab]; + log('source tabs: ', sourceTabs); + const duplicatedTabs = await Tree.moveTabs(sourceTabs, { + duplicate: true, + destinationWindowId: options.destinationWindowId || sourceTabs[0].windowId, + insertAfter: sourceTabs[sourceTabs.length-1] + }); + await Tree.behaveAutoAttachedTabs(duplicatedTabs, { + baseTabs: sourceTabs, + behavior: typeof options.behavior == 'number' ? options.behavior : configs.autoAttachOnDuplicated, + broadcast: true + }); + return duplicatedTabs; +} + +export async function moveTabToStart(tab, options = {}) { + const isMultiselected = options.multiselected === false ? false : tab.$TST.multiselected; + return moveTabsToStart(isMultiselected ? Tab.getSelectedTabs(tab.windowId) : [tab].concat(tab.$TST.descendants)); +} + +export async function moveTabsToStart(movedTabs) { + if (movedTabs.length === 0) + return; + const tab = movedTabs[0]; + const allTabs = tab.pinned ? Tab.getPinnedTabs(tab.windowId) : Tab.getUnpinnedTabs(tab.windowId); + const movedTabsSet = new Set(movedTabs); + let firstOtherTab; + for (const tab of allTabs) { + if (movedTabsSet.has(tab)) + continue; + firstOtherTab = tab; + break; + } + if (firstOtherTab) + await moveTabsWithStructure(movedTabs, { + insertBefore: firstOtherTab, + broadcast: true + }); +} + +export async function moveTabToEnd(tab, options = {}) { + const isMultiselected = options.multiselected === false ? false : tab.$TST.multiselected; + return moveTabsToEnd(isMultiselected ? Tab.getSelectedTabs(tab.windowId) : [tab].concat(tab.$TST.descendants)); +} + +export async function moveTabsToEnd(movedTabs) { + if (movedTabs.length === 0) + return; + const tab = movedTabs[0]; + const allTabs = tab.pinned ? Tab.getPinnedTabs(tab.windowId) : Tab.getUnpinnedTabs(tab.windowId); + const movedTabsSet = new Set(movedTabs); + let lastOtherTabs; + for (let i = allTabs.length - 1; i > -1; i--) { + const tab = allTabs[i]; + if (movedTabsSet.has(tab)) + continue; + lastOtherTabs = tab; + break; + } + if (lastOtherTabs) + await moveTabsWithStructure(movedTabs, { + insertAfter: lastOtherTabs, + broadcast: true + }); +} + +export async function openTabInWindow(tab, options = {}) { + if (options.multiselected !== false && tab.$TST.multiselected) { + return openTabsInWindow(Tab.getSelectedTabs(tab.windowId)); + } + else if (options.withTree) { + return openTabsInWindow([tab, ...tab.$TST.descendants]); + } + else { + const sourceWindow = await browser.windows.get(tab.windowId); + const sourceParams = getWindowParamsFromSource(sourceWindow, options); + const windowParams = { + //active: true, // not supported in Firefox... + tabId: tab.id, + ...sourceParams, + left: sourceParams.left + 20, + top: sourceParams.top + 20, + }; + const win = await browser.windows.create(windowParams).catch(ApiTabs.createErrorHandler()); + return win.id; + } +} + +export async function openTabsInWindow(tabs) { + const movedTabs = await Tree.openNewWindowFromTabs(tabs); + return movedTabs.length > 0 ? movedTabs[0].windowId : null; +} + + +export async function restoreTabs(count) { + const toBeRestoredTabSessions = (await browser.sessions.getRecentlyClosed({ + maxResults: browser.sessions.MAX_SESSION_RESULTS + }).catch(ApiTabs.createErrorHandler())).filter(session => session.tab).slice(0, count); + log('restoreTabs: toBeRestoredTabSessions = ', toBeRestoredTabSessions); + const promisedRestoredTabs = []; + for (const session of toBeRestoredTabSessions.reverse()) { + log('restoreTabs: Tabrestoring session = ', session); + promisedRestoredTabs.push(Tab.doAndGetNewTabs(async () => { + browser.sessions.restore(session.tab.sessionId).catch(ApiTabs.createErrorSuppressor()); + await Tab.waitUntilTrackedAll(); + })); + } + const restoredTabs = Array.from(new Set((await Promise.all(promisedRestoredTabs)).flat())); + log('restoreTabs: restoredTabs = ', restoredTabs); + await Promise.all(restoredTabs.map(tab => tab && Tab.get(tab.id).$TST.opened)); + + if (restoredTabs.length > 0) { + // Parallelly restored tabs can have ghost "active" state, so we need to clear them + const activeTab = Tab.getActiveTab(restoredTabs[0].windowId); + if (restoredTabs.some(tab => tab.id == activeTab.id)) + await TabsInternalOperation.setTabActive(activeTab); + } + + return TreeItem.sort(restoredTabs); +} + + +export async function bookmarkTab(tab, options = {}) { + if (options.multiselected !== false && tab.$TST.multiselected) + return bookmarkTabs(Tab.getSelectedTabs(tab.windowId)); + + if (configs.showDialogInSidebar && + SidebarConnection.isOpen(tab.windowId)) { + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_BOOKMARK_TAB_WITH_DIALOG, + windowId: tab.windowId, + tabId: tab.id + }); + } + else { + Bookmark.bookmarkTab(tab, { + showDialog: true + }); + } +} + +export async function bookmarkTabs(tabs) { + if (tabs.length == 0) + return; + if (configs.showDialogInSidebar && + SidebarConnection.isOpen(tabs[0].windowId)) { + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_BOOKMARK_TABS_WITH_DIALOG, + windowId: tabs[0].windowId, + tabIds: tabs.map(tab => tab.id) + }); + } + else { + Bookmark.bookmarkTabs(tabs, { + showDialog: true + }); + } +} + +export async function reopenInContainer(sourceTabOrTabs, cookieStoreId, options = {}) { + let sourceTabs; + if (Array.isArray(sourceTabOrTabs)) { + sourceTabs = sourceTabOrTabs; + } + else { + const isMultiselected = options.multiselected === false ? false : sourceTabOrTabs.$TST.multiselected; + sourceTabs = isMultiselected ? Tab.getSelectedTabs(sourceTabOrTabs.windowId) : [sourceTabOrTabs]; + } + if (sourceTabs.length === 0) + return []; + const tabs = await TabsOpen.openURIsInTabs(sourceTabs.map(tab => tab.url), { + isOrphan: true, + windowId: sourceTabs[0].windowId, + cookieStoreId + }); + await Tree.behaveAutoAttachedTabs(tabs, { + baseTabs: sourceTabs, + behavior: configs.autoAttachOnDuplicated, + broadcast: true + }); + return tabs; +} + + +SidebarConnection.onMessage.addListener(async (windowId, message) => { + switch (message.type) { + case Constants.kCOMMAND_NEW_TAB_AS: { + const baseTab = Tab.get(message.baseTabId); + if (baseTab) + openNewTabAs({ + baseTab, + as: message.as, + cookieStoreId: message.cookieStoreId, + inBackground: message.inBackground, + url: message.url, + }); + }; break; + + case Constants.kCOMMAND_PERFORM_TABS_DRAG_DROP: + performTreeItemsDragDropWithMessage(message); + break; + + case Constants.kCOMMAND_TOGGLE_MUTED_FROM_SOUND_BUTTON: { + await Tab.waitUntilTracked(message.tabId); + const root = Tab.get(message.tabId); + log('toggle muted state from sound button: ', message, root); + if (!root) + break; + + const multiselected = root.$TST.multiselected; + const tabs = multiselected ? + Tab.getSelectedTabs(root.windowId, { iterator: true }) : + [root] ; + const toBeMuted = (!multiselected && root.$TST.subtreeCollapsed) ? + !root.$TST.maybeMuted : + !root.$TST.muted ; + + log(' toBeMuted: ', toBeMuted); + if (!multiselected && + root.$TST.subtreeCollapsed) { + const tabsInTree = [root, ...root.$TST.descendants]; + let toBeUpdatedTabs = tabsInTree.filter(tab => + // The "audible" possibly become "false" when the tab is + // really audible but muted. + // However, we can think more simply and robustly. + // - We need to mute "audible" tabs. + // - We need to unmute "muted" tabs. + // So, tabs which any of "audible" or "muted" is "true" + // have enough reason to be updated. + (tab.audible || tab.mutedInfo.muted) && + // And we really need to update only tabs not been the + // expected state. + (tab.$TST.muted != toBeMuted) + ); + // but if there is no target tab, we should update all of the tab and descendants. + if (toBeUpdatedTabs.length == 0) + toBeUpdatedTabs = tabsInTree.filter(tab => + tab.$TST.muted != toBeMuted + ); + log(' toBeUpdatedTabs: ', toBeUpdatedTabs); + for (const tab of toBeUpdatedTabs) { + browser.tabs.update(tab.id, { + muted: toBeMuted + }).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } + } + else { + log(' tabs: ', tabs); + for (const tab of tabs) { + browser.tabs.update(tab.id, { + muted: toBeMuted + }).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } + } + }; break; + + case Constants.kCOMMAND_TOGGLE_STICKY: + toggleSticky([Tab.get(message.tabId)]); + return; + + case Constants.kCOMMAND_NEW_WINDOW_FROM_NATIVE_TAB_GROUP: + NativeTabGroups.moveGroupToNewWindow(message); + return; + } +}); + +browser.runtime.onMessage.addListener((message, sender) => { + switch (message.type) { + // for automated tests + case Constants.kCOMMAND_PERFORM_TABS_DRAG_DROP: + performTreeItemsDragDropWithMessage(message); + break; + + case Constants.kCOMMAND_UPDATE_NATIVE_TAB_GROUP: { + const updates = {}; + if ('title' in message) { + updates.title = message.title; + } + if ('color' in message) { + updates.color = message.color; + } + browser.tabGroups.update(message.groupId, updates); + }; break; + + case Constants.kCOMMAND_INVOKE_NATIVE_TAB_GROUP_MENU_PANEL_COMMAND: + switch (message.command) { + case 'addNewTabInGroup': (async () => { + const windowId = message.windowId || sender.tab?.windowId; + const lastMember = TabGroup.getLastMember(message.groupId); + const tab = await TabsOpen.openNewTab({ + insertAfter: lastMember.$TST?.lastDescendant || lastMember, + windowId, + inBackground: false, + }); + NativeTabGroups.addTabsToGroup([tab], message.groupId); + })(); break; + + case 'moveGroupToNewWindow': + NativeTabGroups.moveGroupToNewWindow({ + windowId: message.windowId || sender.tab?.windowId, + groupId: message.groupId, + }); + break; + + case 'saveAndCloseGroup': + case 'deleteGroup': (async () => { + const windowId = message.windowId || sender.tab?.windowId; + const members = TabGroup.getMembers(message.groupId); + const canceled = (await browser.runtime.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TABS_CLOSING, + tabs: members.map(tab => tab.$TST.sanitized), + windowId, + }).catch(ApiTabs.createErrorHandler())) === false; + if (canceled) + return; + TabsInternalOperation.removeTabs(members); + })(); break; + + case 'ungroupTabs': + case 'cancel': { + const members = TabGroup.getMembers(message.groupId); + NativeTabGroups.removeTabsFromGroup(members); + }; break; + + case 'done': + break; + } + break; + } +}); + + +async function collectBookmarkItems(root, { recursively, grouped } = {}) { + let items = await browser.bookmarks.getChildren(root.id); + if (recursively) { + let expandedItems = []; + for (const item of items) { + switch (item.type) { + case 'bookmark': + expandedItems.push(item); + break; + case 'folder': + expandedItems = expandedItems.concat(await collectBookmarkItems(item, { recursively })); + break; + } + } + items = expandedItems; + } + else { + items = items.filter(item => item.type == 'bookmark'); + } + if (grouped || + countMatched(items, item => !Bookmark.BOOKMARK_TITLE_DESCENDANT_MATCHER.test(item.title)) > 1) { + for (const item of items) { + item.title = Bookmark.BOOKMARK_TITLE_DESCENDANT_MATCHER.test(item.title) ? + item.title.replace(Bookmark.BOOKMARK_TITLE_DESCENDANT_MATCHER, '>$1 ') : + `> ${item.title}`; + } + items.unshift({ + title: '', + url: TabsGroup.makeGroupTabURI({ + title: root.title, + ...TabsGroup.temporaryStateParams(configs.groupTabTemporaryStateForNewTabsFromBookmarks), + }), + group: true, + discarded: false, + }); + } + return items; +} + +export async function openBookmarksWithStructure(items, { activeIndex = 0, discarded } = {}) { + if (typeof discarded == 'undefined') + discarded = configs.openAllBookmarksWithStructureDiscarded; + + const structure = Bookmark.getTreeStructureFromBookmarks(items); + + const windowId = TabsStore.getCurrentWindowId() || (await browser.windows.getCurrent()).id; + const tabs = await TabsOpen.openURIsInTabs( + // we need to isolate it - unexpected parameter like "index" will break the behavior. + items.map(bookmark => ({ + url: bookmark.url, + title: bookmark.title, + cookieStoreId: bookmark.cookieStoreId, + })), + { + windowId, + isOrphan: true, + inBackground: true, + fixPositions: true, + discarded, + } + ); + + if (tabs.length > activeIndex) + TabsInternalOperation.activateTab(tabs[activeIndex]); + if (tabs.length == structure.length) + await Tree.applyTreeStructureToTabs(tabs, structure); + + // tabs can be opened at middle of an existing tree due to browser.tabs.insertAfterCurrent=true + const referenceTabs = TreeBehavior.calculateReferenceItemsFromInsertionPosition(tabs, { + context: Constants.kINSERTION_CONTEXT_CREATED, + insertAfter: tabs[0].$TST.previousTab, + insertBefore: tabs[tabs.length - 1].$TST.nextTab + }); + if (referenceTabs.parent) + await Tree.attachTabTo(tabs[0], referenceTabs.parent, { + insertAfter: referenceTabs.insertAfter, + insertBefore: referenceTabs.insertBefore + }); +} + +export async function openAllBookmarksWithStructure(id, { discarded, recursively, grouped } = {}) { + if (typeof discarded == 'undefined') + discarded = configs.openAllBookmarksWithStructureDiscarded; + if (typeof grouped == 'undefined') + grouped = !configs.suppressGroupTabForStructuredTabsFromBookmarks; + + let item = await browser.bookmarks.get(id); + if (Array.isArray(item)) + item = item[0]; + if (!item) + return; + + if (item.type != 'folder') { + item = await browser.bookmarks.get(item.parentId); + if (Array.isArray(item)) + item = item[0]; + } + + const items = await collectBookmarkItems(item, { + recursively, + grouped, + }); + const activeIndex = items.findIndex(item => !item.group); + + openBookmarksWithStructure(items, { activeIndex, discarded }); +} diff --git a/waterfox/browser/components/sidebar/background/context-menu.js b/waterfox/browser/components/sidebar/background/context-menu.js new file mode 100644 index 000000000000..75c86f7da7e7 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/context-menu.js @@ -0,0 +1,764 @@ +/* +# 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'; + +import { + log as internalLogger, + configs, +} from '/common/common.js'; + +import * as ApiTabs from '/common/api-tabs.js'; +import * as Bookmark from '/common/bookmark.js'; +import * as Sync from '/common/sync.js'; +import * as TSTAPI from '/common/tst-api.js'; + +import { Tab } from '/common/TreeItem.js'; + +import * as Commands from './commands.js'; +import * as TabContextMenu from './tab-context-menu.js'; + +function log(...args) { + internalLogger('background/context-menu', ...args); +} + +export async function init() { + return Promise.all([ + addTabItems(), + addBookmarkItems(), + ]); +} + +const SAFE_CREATE_PROPERTIES = [ + 'checked', + 'contexts', + 'documentUrlPatterns', + 'enabled', + 'icons', + 'id', + 'parentId', + 'title', + 'type', + 'viewTypes', + 'visible' +]; + +function getSafeCreateParams(params) { + const safeParams = {}; + for (const property of SAFE_CREATE_PROPERTIES) { + if (property in params) + safeParams[property] = params[property]; + } + return safeParams; +} + +const manifest = browser.runtime.getManifest(); + +const kROOT_TAB_ITEM = 'ws'; +const kROOT_BOOKMARK_ITEM = 'ws-bookmark'; + +const mTabItemsById = { + 'reloadTree': { + title: browser.i18n.getMessage('context_reloadTree_label'), + titleMultiselected: browser.i18n.getMessage('context_reloadTree_label_multiselected') + }, + 'reloadDescendants': { + title: browser.i18n.getMessage('context_reloadDescendants_label'), + titleMultiselected: browser.i18n.getMessage('context_reloadDescendants_label_multiselected') + }, + // This item won't be handled by the onClicked handler, so you may need to handle it with something experiments API. + 'unblockAutoplayTree': { + title: browser.i18n.getMessage('context_unblockAutoplayTree_label'), + titleMultiselected: browser.i18n.getMessage('context_unblockAutoplayTree_label_multiselected'), + requireAutoplayBlockedTab: true, + }, + // This item won't be handled by the onClicked handler, so you may need to handle it with something experiments API. + 'unblockAutoplayDescendants': { + title: browser.i18n.getMessage('context_unblockAutoplayDescendants_label'), + titleMultiselected: browser.i18n.getMessage('context_unblockAutoplayDescendants_label_multiselected'), + requireAutoplayBlockedDescendant: true, + }, + 'toggleMuteTree': { + titleMuteTree: browser.i18n.getMessage('context_toggleMuteTree_label_mute'), + titleMultiselectedMuteTree: browser.i18n.getMessage('context_toggleMuteTree_label_multiselected_mute'), + titleUnmuteTree: browser.i18n.getMessage('context_toggleMuteTree_label_unmute'), + titleMultiselectedUnmuteTree: browser.i18n.getMessage('context_toggleMuteTree_label_multiselected_unmute') + }, + 'toggleMuteDescendants': { + titleMuteDescendant: browser.i18n.getMessage('context_toggleMuteDescendants_label_mute'), + titleMultiselectedMuteDescendant: browser.i18n.getMessage('context_toggleMuteDescendants_label_multiselected_mute'), + titleUnmuteDescendant: browser.i18n.getMessage('context_toggleMuteDescendants_label_unmute'), + titleMultiselectedUnmuteDescendant: browser.i18n.getMessage('context_toggleMuteDescendants_label_multiselected_unmute') + }, + 'separatorAfterReload': { + type: 'separator' + }, + 'closeTree': { + title: browser.i18n.getMessage('context_closeTree_label'), + titleMultiselected: browser.i18n.getMessage('context_closeTree_label_multiselected') + }, + 'closeDescendants': { + title: browser.i18n.getMessage('context_closeDescendants_label'), + titleMultiselected: browser.i18n.getMessage('context_closeDescendants_label_multiselected'), + requireTree: true, + }, + 'closeOthers': { + title: browser.i18n.getMessage('context_closeOthers_label'), + titleMultiselected: browser.i18n.getMessage('context_closeOthers_label_multiselected') + }, + 'separatorAfterClose': { + type: 'separator' + }, + 'toggleSticky': { + titleStick: browser.i18n.getMessage('context_toggleSticky_label_stick'), + titleMultiselectedStick: browser.i18n.getMessage('context_toggleSticky_label_multiselected_stick'), + titleUnstick: browser.i18n.getMessage('context_toggleSticky_label_unstick'), + titleMultiselectedUnstick: browser.i18n.getMessage('context_toggleSticky_label_multiselected_unstick'), + requireNormal: true, + }, + 'separatorAfterToggleSticky': { + type: 'separator' + }, + 'collapseTree': { + title: browser.i18n.getMessage('context_collapseTree_label'), + titleMultiselected: browser.i18n.getMessage('context_collapseTree_label_multiselected'), + requireTree: true, + }, + 'collapseTreeRecursively': { + title: browser.i18n.getMessage('context_collapseTreeRecursively_label'), + titleMultiselected: browser.i18n.getMessage('context_collapseTreeRecursively_label_multiselected'), + requireTree: true, + }, + 'collapseAll': { + title: browser.i18n.getMessage('context_collapseAll_label'), + hideOnMultiselected: true + }, + 'expandTree': { + title: browser.i18n.getMessage('context_expandTree_label'), + titleMultiselected: browser.i18n.getMessage('context_expandTree_label_multiselected'), + requireTree: true, + }, + 'expandTreeRecursively': { + title: browser.i18n.getMessage('context_expandTreeRecursively_label'), + titleMultiselected: browser.i18n.getMessage('context_expandTreeRecursively_label_multiselected'), + requireTree: true, + }, + 'expandAll': { + title: browser.i18n.getMessage('context_expandAll_label'), + hideOnMultiselected: true + }, + 'separatorAfterCollapseExpand': { + type: 'separator' + }, + 'bookmarkTree': { + title: browser.i18n.getMessage('context_bookmarkTree_label'), + titleMultiselected: browser.i18n.getMessage('context_bookmarkTree_label_multiselected') + }, + 'sendTreeToDevice': { + title: browser.i18n.getMessage('context_sendTreeToDevice_label'), + titleMultiselected: browser.i18n.getMessage('context_sendTreeToDevice_label_multiselected') + }, + 'separatorAfterBookmark': { + type: 'separator' + }, + 'collapsed': { + title: browser.i18n.getMessage('context_collapsed_label'), + requireTree: true, + type: 'checkbox' + }, + 'pinnedTab': { + title: browser.i18n.getMessage('context_pinnedTab_label'), + type: 'radio' + }, + 'unpinnedTab': { + title: browser.i18n.getMessage('context_unpinnedTab_label'), + type: 'radio' + } +}; +const mTabItems = []; +const mGroupedTabItems = []; +const mGroupedTabItemsById = {}; +for (const id of Object.keys(mTabItemsById)) { + const item = mTabItemsById[id]; + item.id = id; + item.configKey = `context_${id}`; + item.checked = false; // initialize as unchecked + item.enabled = true; + // Access key is not supported by WE API. + // See also: https://bugzilla.mozilla.org/show_bug.cgi?id=1320462 + item.titleWithoutAccesskey = item.title?.replace(/\(&[a-z]\)|&([a-z])/i, '$1'); + item.titleMultiselectedWithoutAccesskey = item.titleMultiselected?.replace(/\(&[a-z]\)|&([a-z])/i, '$1'); + item.type = item.type || 'normal'; + item.contexts = ['tab']; + item.lastVisible = item.visible = false; + item.lastTitle = item.title; + mTabItems.push(item); + + const groupedItem = { + ...item, + id: `grouped:${id}`, + parentId: kROOT_TAB_ITEM + }; + mGroupedTabItems.push(groupedItem); + mGroupedTabItemsById[groupedItem.id] = groupedItem; +} + +const mTabSeparator = { + id: `separatprBefore${kROOT_TAB_ITEM}`, + type: 'separator', + contexts: ['tab'], + viewTypes: ['sidebar'], + documentUrlPatterns: [`moz-extension://${location.host}/*`], + visible: false, + lastVisible: false +}; +const mTabRootItem = { + id: kROOT_TAB_ITEM, + type: 'normal', + contexts: ['tab'], + title: browser.i18n.getMessage('context_menu_label'), + icons: manifest.icons, + visible: false, + lastVisible: false +}; + +const mAllTabItems = [ + mTabSeparator, + mTabRootItem, + ...mTabItems, + ...mGroupedTabItems +]; + +function addTabItems() { + const promises = []; + if (addTabItems.done) { + for (const item of mAllTabItems) { + promises.push(browser.menus.remove(item.id)); + } + } + + for (const item of mAllTabItems) { + const params = getSafeCreateParams(item); + promises.push(browser.menus.create(params)); + if (item.id == mTabSeparator.id || + addTabItems.done) + continue; + promises.push(TabContextMenu.onMessageExternal({ + type: TSTAPI.kCONTEXT_MENU_CREATE, + params + }, browser.runtime)); + } + + addTabItems.done = true; + return Promise.all(promises); +} +addTabItems.done = false; + + +const mBookmarkItemsById = { + openAllBookmarksWithStructure: { + title: browser.i18n.getMessage('context_openAllBookmarksWithStructure_label') + }, + openAllBookmarksWithStructureRecursively: { + title: browser.i18n.getMessage('context_openAllBookmarksWithStructureRecursively_label') + } +}; +const mBookmarkItems = []; +const mGroupedBookmarkItems = [] +for (const id of Object.keys(mBookmarkItemsById)) { + const item = mBookmarkItemsById[id]; + item.id = id; + item.contexts = ['bookmark']; + item.configKey = `context_${id}`; + const groupedItem = { + ...item, + id: `grouped:${id}`, + parentId: kROOT_BOOKMARK_ITEM, + ungroupedItem: item + }; + item.icons = manifest.icons; + + mBookmarkItems.push(item); + + mBookmarkItemsById[groupedItem.id] = groupedItem; + mGroupedBookmarkItems.push(groupedItem); +} + +const mBookmarkSeparator = { + id: `separatprBefore${kROOT_BOOKMARK_ITEM}`, + type: 'separator', + contexts: ['bookmark'], + viewTypes: ['sidebar'], + documentUrlPatterns: [`moz-extension://${location.host}/*`], + visible: false, + lastVisible: false +}; +const mBookmarkRootItem = { + id: kROOT_BOOKMARK_ITEM, + type: 'normal', + contexts: ['bookmark'], + title: manifest.name, + icons: manifest.icons, + visible: false, + lastVisible: false +}; + +const mAllBookmarkItems = [ + mBookmarkSeparator, + mBookmarkRootItem, + ...mBookmarkItems, + ...mGroupedBookmarkItems +]; + +function addBookmarkItems() { + const promises = []; + if (addBookmarkItems.done) { + for (const item of mAllBookmarkItems) { + promises.push(browser.menus.remove(item.id)); + } + } + for (const item of mAllBookmarkItems) { + promises.push(browser.menus.create(getSafeCreateParams(item))); + } + addBookmarkItems.done = true; + return Promise.all(promises); +} +addBookmarkItems.done = false; + + +// Re-register items to put them after +// top level items added by other addons. +TabContextMenu.onTopLevelItemAdded.addListener(reserveToRefreshItems); + +function reserveToRefreshItems() { + if (reserveToRefreshItems.invoked) + return; + reserveToRefreshItems.invoked = true; + setTimeout(() => { // because window.requestAnimationFrame is decelerate for an invisible document. + reserveToRefreshItems.invoked = false; + addTabItems(); + addBookmarkItems(); + }, 0); +} + +function updateItem(id, params) { + log('updateItem ', id, params); + browser.menus.update(id, params).catch(ApiTabs.createErrorSuppressor()); + TabContextMenu.onMessageExternal({ + type: TSTAPI.kCONTEXT_MENU_UPDATE, + params: [id, params] + }, browser.runtime); +} + +function updateItemsVisibility(items, { forceVisible = null, multiselected = false, hasUnmutedTab = false, hasUnmutedDescendant = false, hasAutoplayBlockedTab = false, hasAutoplayBlockedDescendant = false, sticky = false, hidden = false } = {}) { + log('updateItemsVisibility ', items, { forceVisible, multiselected, hasUnmutedTab, hasUnmutedDescendant, hasAutoplayBlockedTab, hasAutoplayBlockedDescendant, sticky }); + let updated = false; + let visibleItemsCount = 0; + let visibleNormalItemsCount = 0; + let lastSeparator; + for (const item of items) { + if (item.type == 'separator') { + if (lastSeparator) { + if (lastSeparator.lastVisible) { + updateItem(lastSeparator.id, { visible: false }); + lastSeparator.lastVisible = false; + updated = true; + } + } + lastSeparator = item; + } + else { + const title = Commands.getMenuItemTitle(item, { multiselected, hasUnmutedTab, hasUnmutedDescendant, sticky }); + let visible = !hidden && (!(item.configKey in configs) || configs[item.configKey] || false); + log('checking ', item.id, { + config: visible, + multiselected: item.hideOnMultiselected && multiselected, + lastVisible: item.lastVisible, + forceVisible, + }); + if (forceVisible !== null && forceVisible !== undefined) + visible = forceVisible; + if ((item.hideOnMultiselected && multiselected) || + (item.requireAutoplayBlockedTab && !hasAutoplayBlockedTab) || + (item.requireAutoplayBlockedDescendant && !hasAutoplayBlockedDescendant)) + visible = false; + if (visible) { + if (lastSeparator) { + updateItem(lastSeparator.id, { visible: visibleNormalItemsCount > 0 }); + lastSeparator.lastVisible = true; + lastSeparator = null; + updated = true; + visibleNormalItemsCount = 0; + } + visibleNormalItemsCount++; + visibleItemsCount++; + } + const updatedParams = {}; + if (visible !== item.lastVisible) { + updatedParams.visible = visible; + item.lastVisible = visible; + } + if (title !== item.lastTitle) { + updatedParams.title = title; + item.lastTitle = title; + } + if (Object.keys(updatedParams).length == 0) + continue; + updateItem(item.id, updatedParams); + updated = true; + } + } + if (lastSeparator?.lastVisible) { + updateItem(lastSeparator.id, { visible: false }); + lastSeparator.lastVisible = false; + updated = true; + } + return { updated, visibleItemsCount }; +} + +async function updateItems({ multiselected, hasUnmutedTab, hasUnmutedDescendant, hasAutoplayBlockedTab, hasAutoplayBlockedDescendant, sticky, hidden } = {}) { + let updated = false; + + const groupedItems = updateItemsVisibility(mGroupedTabItems, { multiselected, hasUnmutedTab, hasUnmutedDescendant, hasAutoplayBlockedTab, hasAutoplayBlockedDescendant, sticky, hidden }); + if (groupedItems.updated) + updated = true; + + const separatorVisible = !hidden && configs.emulateDefaultContextMenu && groupedItems.visibleItemsCount > 0; + if (separatorVisible != mTabSeparator.lastVisible) { + updateItem(mTabSeparator.id, { visible: separatorVisible }); + mTabSeparator.lastVisible = separatorVisible; + updated = true; + } + + const grouped = !hidden && configs.emulateDefaultContextMenu && groupedItems.visibleItemsCount > 1; + if (grouped != mTabRootItem.lastVisible) { + updateItem(mTabRootItem.id, { visible: grouped }); + mTabRootItem.lastVisible = grouped; + updated = true; + } + + const topLevelItems = updateItemsVisibility(mTabItems, { forceVisible: grouped ? false : null, multiselected, hasUnmutedTab, hasUnmutedDescendant, hasAutoplayBlockedTab, hasAutoplayBlockedDescendant, sticky, hidden }); + if (topLevelItems.updated) + updated = true; + + if (mGroupedTabItemsById['grouped:sendTreeToDevice'].lastVisible && + await TabContextMenu.updateSendToDeviceItems('grouped:sendTreeToDevice', { + manage: navigator.userAgent.includes('Fennec'), // see also https://github.com/piroor/treestyletab/issues/3174 + })) + updated = true; + + return updated; +} + +export function onClick(info, tab) { + if (info.bookmarkId) + return onBookmarkItemClick(info); + else + return onTabItemClick(info, tab); +} +browser.menus.onClicked.addListener(onClick); + +function onTabItemClick(info, tab) { + // Extra context menu commands won't be available on the blank area of the tab bar. + if (!tab) + return; + + log('context menu item clicked: ', info, tab); + + const contextTab = Tab.get(tab.id); + const contextTabs = contextTab.$TST.multiselected ? Tab.getSelectedTabs(contextTab.windowId) : [contextTab]; + + const itemId = info.menuItemId.replace(/^(?:grouped:|context_topLevel_)/, ''); + if (mTabItemsById[itemId] && + mTabItemsById[itemId].type == 'checkbox') + mTabItemsById[itemId].checked = !mTabItemsById[itemId].checked; + + const inverted = info.button == 1; + switch (itemId) { + case 'reloadTree': + if (inverted) + Commands.reloadDescendants(contextTabs); + else + Commands.reloadTree(contextTabs); + break; + case 'reloadDescendants': + if (inverted) + Commands.reloadTree(contextTabs); + else + Commands.reloadDescendants(contextTabs); + break; + + case 'toggleMuteTree': + if (inverted) + Commands.toggleMuteDescendants(contextTabs); + else + Commands.toggleMuteTree(contextTabs); + break; + case 'toggleMuteDescendants': + if (inverted) + Commands.toggleMuteTree(contextTabs); + else + Commands.toggleMuteDescendants(contextTabs); + break; + + case 'closeTree': + if (inverted) + Commands.closeDescendants(contextTabs); + else + Commands.closeTree(contextTabs); + break; + case 'closeDescendants': + if (inverted) + Commands.closeTree(contextTabs); + else + Commands.closeDescendants(contextTabs); + break; + case 'closeOthers': + Commands.closeOthers(contextTabs); + break; + + case 'toggleSticky': + Commands.toggleSticky(contextTabs, !contextTab.$TST.sticky); + break; + + case 'collapseTree': + Commands.collapseTree(contextTabs, { recursively: inverted }); + break; + case 'collapseTreeRecursively': + Commands.collapseTree(contextTabs, { recursively: !inverted }); + break; + case 'collapseAll': + Commands.collapseAll(contextTab.windowId); + break; + case 'expandTree': + Commands.expandTree(contextTabs, { recursively: inverted }); + break; + case 'expandTreeRecursively': + Commands.expandTree(contextTabs, { recursively: !inverted }); + break; + case 'expandAll': + Commands.expandAll(contextTab.windowId); + break; + + case 'bookmarkTree': + Commands.bookmarkTree(contextTabs); + break; + + case 'sendTreeToDevice:all': + Sync.sendTabsToAllDevices(contextTabs, { recursively: true }); + break; + + case 'collapsed': + if (info.wasChecked) + Commands.expandTree(contextTab); + else + Commands.collapseTree(contextTab); + break; + case 'pinnedTab': { + const tabs = Tab.getPinnedTabs(contextTab.windowId); + if (tabs.length > 0) + browser.tabs.update(tabs[0].id, { active: true }) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + }; break; + case 'unpinnedTab': { + const tabs = Tab.getUnpinnedTabs(tab.windowId); + if (tabs.length > 0) + browser.tabs.update(tabs[0].id, { active: true }) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + }; break; + + default: { + const sendToDeviceMatch = info.menuItemId.match(/^sendTreeToDevice:device:(.+)$/); + if (contextTab && + sendToDeviceMatch) + Sync.sendTabsToDevice( + contextTabs, + { to: sendToDeviceMatch[1], + recursively: true } + ); + }; break; + } +} +TabContextMenu.onTSTItemClick.addListener(onTabItemClick); + +async function onBookmarkItemClick(info) { + switch (info.menuItemId.replace(/^grouped:/, '')) { + case 'openAllBookmarksWithStructure': + Commands.openAllBookmarksWithStructure(info.bookmarkId, { recursively: false }); + break; + + case 'openAllBookmarksWithStructureRecursively': + Commands.openAllBookmarksWithStructure(info.bookmarkId, { recursively: true }); + break; + } +} + +async function onShown(info, tab) { + if (info.contexts.includes('tab')) + await onTabContextMenuShown(info, tab); + else if (info.contexts.includes('bookmark')) + onBookmarkContextMenuShown(info); +} +browser.menus.onShown.addListener(onShown); + +let mLastContextTabId = null; +async function onTabContextMenuShown(info, tab) { + const contextTabId = tab?.id; + mLastContextTabId = contextTabId; + + tab = tab && Tab.get(contextTabId); + const multiselected = tab?.$TST.multiselected; + const contextTabs = multiselected ? Tab.getSelectedTabs(tab.windowId) : tab ? [tab] : []; + const hasChild = contextTabs.length > 0 && contextTabs.some(tab => tab.$TST.hasChild); + const subtreeCollapsed = contextTabs.length > 0 && contextTabs.some(tab => tab.$TST.subtreeCollapsed); + const grouped = contextTabs.length > 0 && contextTabs.some(tab => tab.$TST.isGroupTab); + const { hasUnmutedTab, hasUnmutedDescendant } = Commands.getUnmutedState(contextTabs); + const { hasAutoplayBlockedTab, hasAutoplayBlockedDescendant } = Commands.getAutoplayBlockedState(contextTabs); + + let updated = await updateItems({ + multiselected, + hasUnmutedTab, + hasUnmutedDescendant, + hasAutoplayBlockedTab, + hasAutoplayBlockedDescendant, + sticky: tab?.$TST.sticky, + hidden: !configs.showTreeCommandsInTabsContextMenuGlobally && info.viewType != 'sidebar', + }); + if (mLastContextTabId != contextTabId) + return; // Skip further operations if the menu was already reopened on a different context tab. + + for (const item of mTabItems) { + let newVisible; + let newEnabled; + if (item.id == 'sendTreeToDevice' && + item.visible) { + newVisible = contextTabs.filter(Sync.isSendableTab).length > 0; + newEnabled = ( + hasChild && + Sync.getOtherDevices().length > 0 + ); + } + else if (item.requireTree) { + newEnabled = hasChild; + switch (item.id) { + case 'collapseTree': + if (subtreeCollapsed) + newEnabled = false; + break; + case 'expandTree': + if (!subtreeCollapsed) + newEnabled = false; + break; + } + } + else if (item.requireMultiselected) { + newEnabled = multiselected; + } + else if (item.requireGrouped) { + newEnabled = grouped; + } + else if (item.requireNormal) { + newEnabled = tab?.pinned; + } + else { + continue; + } + + if ((newVisible === undefined || + newVisible == !!item.visible) && + (newEnabled === undefined || + newEnabled == !!item.enabled)) + continue; + + const params = {}; + if (newVisible !== undefined && + newVisible != !!item.visible) + params.visible = item.visible = !!newVisible; + if (newEnabled !== undefined && + newEnabled != !!item.enabled) + params.enabled = item.enabled = !!newEnabled; + + updateItem(item.id, params); + updateItem(`grouped:${item.id}`, params); + updated = true; + } + + { + const canExpand = hasChild && subtreeCollapsed; + mTabItemsById.collapsed.checked = canExpand; + const params = { + checked: canExpand + }; + updateItem('collapsed', params); + updateItem(`grouped:collapsed`, params); + updated = true; + } + + if (updated) + browser.menus.refresh().catch(ApiTabs.createErrorSuppressor()); +} +TabContextMenu.onTSTTabContextMenuShown.addListener(onTabContextMenuShown); + +let mLastContextItemId = null; +async function onBookmarkContextMenuShown(info) { + const contextItemId = info.bookmarkId; + mLastContextItemId = contextItemId; + + let isFolder = true; + if (info.bookmarkId) { + const item = await Bookmark.getItemById(info.bookmarkId); + if (mLastContextItemId != contextItemId) + return; // Skip further operations if the menu was already reopened on a different context item. + isFolder = ( + item.type == 'folder' || + (item.type == 'bookmark' && + /^place:parent=([^&]+)$/.test(item.url)) + ); + } + + let visibleItemCount = 0; + + mBookmarkItemsById.openAllBookmarksWithStructure.visible = !!( + isFolder && + configs[mBookmarkItemsById.openAllBookmarksWithStructure.configKey] && + ++visibleItemCount + ); + mBookmarkItemsById.openAllBookmarksWithStructureRecursively.visible = !!( + isFolder && + configs[mBookmarkItemsById.openAllBookmarksWithStructureRecursively.configKey] && + ++visibleItemCount + ); + + for (const item of mGroupedBookmarkItems) { + item.visible = !!( + visibleItemCount > 1 && + item.ungroupedItem.visible + ); + if (item.visible) + item.ungroupedItem.visible = false; + } + for (const item of [...mBookmarkItems, ...mGroupedBookmarkItems]) { + browser.menus.update(item.id, { + visible: !!item.visible, + }); + } + + browser.menus.update(mBookmarkSeparator.id, { + visible: visibleItemCount > 0 + }); + browser.menus.update(mBookmarkRootItem.id, { + visible: visibleItemCount > 1 + }); + browser.menus.refresh().catch(ApiTabs.createErrorSuppressor()); +} + + +export function getItemIdsWithIcon() { + return [ + kROOT_TAB_ITEM, + kROOT_BOOKMARK_ITEM, + ...Object.keys(mBookmarkItemsById), + ]; +} diff --git a/waterfox/browser/components/sidebar/background/duplicated-tab-detection.js b/waterfox/browser/components/sidebar/background/duplicated-tab-detection.js new file mode 100644 index 000000000000..93b46d35ffc5 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/duplicated-tab-detection.js @@ -0,0 +1,61 @@ +/* +# 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'; + +import { + configs, +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; + +import { Tab } from '/common/TreeItem.js'; + +async function doTest() { + const win = await browser.windows.getLastFocused({ populate: true }); + const tab = win.tabs.find(tab => tab.active); + + const maxTry = 10; + const promises = []; + const duplicatedTabIds = []; + for (let i = 0; i < maxTry; i++) { + promises.push(browser.tabs.duplicate(tab.id).then(async duplicated => { + await Tab.waitUntilTracked(duplicated.id); + const tracked = Tab.get(duplicated.id); + const uniqueId = await tracked.$TST.promisedUniqueId; + duplicatedTabIds.push(duplicated.id); + return uniqueId?.duplicated; + })); + } + const successCount = (await Promise.all(promises)).filter(result => !!result).length; + await browser.tabs.remove(duplicatedTabIds); + return successCount / maxTry; +} + +async function autoDetectSuitableDelay() { + configs.delayForDuplicatedTabDetection = 0; + let successRate = await doTest(); + if (successRate == 1) + return; + + configs.delayForDuplicatedTabDetection = 10; + while (successRate < 1) { + configs.delayForDuplicatedTabDetection = Math.round(configs.delayForDuplicatedTabDetection * (1 / Math.max(successRate, 0.5))); + successRate = await doTest(); + } +} + +browser.runtime.onMessage.addListener((message, _sender) => { + if (!message || !message.type) + return; + + switch (message.type) { + case Constants.kCOMMAND_TEST_DUPLICATED_TAB_DETECTION: + return doTest(); + + case Constants.kCOMMAND_AUTODETECT_DUPLICATED_TAB_DETECTION_DELAY: + autoDetectSuitableDelay(); + return; + } +}); diff --git a/waterfox/browser/components/sidebar/background/handle-autoplay-blocking.js b/waterfox/browser/components/sidebar/background/handle-autoplay-blocking.js new file mode 100644 index 000000000000..177dc9f8c85a --- /dev/null +++ b/waterfox/browser/components/sidebar/background/handle-autoplay-blocking.js @@ -0,0 +1,150 @@ +/* +# 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'; + +import { + configs, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from '/common/constants.js'; +import * as SidebarConnection from '/common/sidebar-connection.js'; + +import { Tab } from '/common/TreeItem.js'; + +import * as Background from './background.js'; + +function log(...args) { + if (configs.debug) + console.log(...args); +} + +function uniqTabsAndDescendantsSet(tabs) { + if (!Array.isArray(tabs)) + tabs = [tabs]; + return Array.from(new Set(tabs.map(tab => [tab].concat(tab.$TST.descendants)).flat())).sort(Tab.compare); +} + +function unblockAutoplayTree(tabs) { + const tabsToUpdate = []; + let shouldUnblockAutoplay = false; + for (const tab of uniqTabsAndDescendantsSet(tabs)) { + if (!shouldUnblockAutoplay && tab.$TST.autoplayBlocked) + shouldUnblockAutoplay = true; + tabsToUpdate.push(tab); + } + browser.waterfoxBridge.unblockAutoplay(tabsToUpdate.map(tab => tab.id)); +} + +function unblockAutoplayDescendants(rootTabs) { + const rootTabsSet = new Set(rootTabs); + const tabsToUpdate = []; + let shouldUnblockAutoplay = false; + for (const tab of uniqTabsAndDescendantsSet(rootTabs)) { + if (rootTabsSet.has(tab)) + continue; + if (!shouldUnblockAutoplay && tab.$TST.autoplayBlocked) + shouldUnblockAutoplay = true; + tabsToUpdate.push(tab); + } + browser.waterfoxBridge.unblockAutoplay(tabsToUpdate.map(tab => tab.id)); +} + +browser.menus.onClicked.addListener((info, contextTab) => { + contextTab = contextTab && Tab.get(contextTab.id); + const contextTabs = contextTab.$TST.multiselected ? Tab.getSelectedTabs(contextTab.windowId) : [contextTab]; + const inverted = info.button == 1; + + switch (info.menuItemId.replace(/^grouped:/, '')) { + case 'context_unblockAutoplay': + browser.waterfoxBridge.unblockAutoplay(contextTabs.map(tab => tab.id)); + break; + + case 'context_topLevel_unblockAutoplayTree': + case 'unblockAutoplayTree': + if (inverted) + unblockAutoplayDescendants(contextTabs); + else + unblockAutoplayTree(contextTabs); + break; + + case 'context_topLevel_unblockAutoplayDescendants': + case 'unblockAutoplayDescendants': + if (inverted) + unblockAutoplayTree(contextTabs); + else + unblockAutoplayDescendants(contextTabs); + break; + } +}); + +SidebarConnection.onMessage.addListener(async (windowId, message) => { + switch (message.type) { + case Constants.kCOMMAND_UNBLOCK_AUTOPLAY_FROM_SOUND_BUTTON: { + await Tab.waitUntilTracked(message.tabId); + const root = Tab.get(message.tabId); + log('unblock autoplay from sound button: ', message, root); + if (!root) + break; + + const multiselected = root.$TST.multiselected; + const tabs = multiselected ? + Tab.getSelectedTabs(root.windowId, { iterator: true }) : + [root] ; + + if (!multiselected && + root.$TST.subtreeCollapsed) { + const tabsInTree = [root, ...root.$TST.descendants]; + const toBeUpdatedTabs = tabsInTree.filter(tab => tab.$TST.autoplayBlocked); + log(' toBeUpdatedTabs: ', toBeUpdatedTabs); + browser.waterfoxBridge.unblockAutoplay(toBeUpdatedTabs.map(tab => tab.id)); + } + else { + log(' tabs: ', tabs); + browser.waterfoxBridge.unblockAutoplay(tabs.map(tab => tab.id)); + } + }; break; + } +}); + +browser.commands.onCommand.addListener(async command => { + let activeTabs = await browser.tabs.query({ + active: true, + currentWindow: true, + }).catch(ApiTabs.createErrorHandler()); + if (activeTabs.length == 0) + activeTabs = await browser.tabs.query({ + currentWindow: true, + }).catch(ApiTabs.createErrorHandler()); + const activeTab = Tab.get(activeTabs[0].id); + const selectedTabs = activeTab.$TST.multiselected ? Tab.getSelectedTabs(activeTab.windowId) : [activeTab]; + + switch (command) { + case 'unblockAutoplayTree': + unblockAutoplayTree(selectedTabs); + return; + + case 'unblockAutoplayDescendants': + unblockAutoplayDescendants(selectedTabs); + return; + } +}); + + +Background.onReady.addListener(() => { + browser.waterfoxBridge.listAutoplayBlockedTabs().then(tabs => { + for (const tab of tabs) { + Tab.get(tab.id)?.$TST.addState(Constants.kTAB_STATE_AUTOPLAY_BLOCKED); + } + }); +}); + +browser.waterfoxBridge.onAutoplayBlocked.addListener(tab => { + Tab.get(tab.id)?.$TST.addState(Constants.kTAB_STATE_AUTOPLAY_BLOCKED); +}); + +browser.waterfoxBridge.onAutoplayUnblocked.addListener(tab => { + Tab.get(tab.id)?.$TST.removeState(Constants.kTAB_STATE_AUTOPLAY_BLOCKED); +}); diff --git a/waterfox/browser/components/sidebar/background/handle-chrome-menu-commands.js b/waterfox/browser/components/sidebar/background/handle-chrome-menu-commands.js new file mode 100644 index 000000000000..665c9c5c6da4 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/handle-chrome-menu-commands.js @@ -0,0 +1,32 @@ +/* +# 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'; + +import { + configs, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as TabsStore from '/common/tabs-store.js'; + +import * as Commands from './commands.js'; + +browser.waterfoxBridge.onMenuCommand.addListener(async info => { + switch (info.itemId) { + case 'tabs-sidebar-newTab': { + const behavior = info.button == 1 ? + configs.autoAttachOnNewTabButtonMiddleClick : + (info.ctrlKey || info.metaKey) ? + configs.autoAttachOnNewTabButtonAccelClick : + configs.autoAttachOnNewTabCommand; + const win = await browser.windows.getLastFocused({ populate: true }).catch(ApiTabs.createErrorHandler()); + const activeTab = TabsStore.activeTabInWindow.get(win.id); + Commands.openNewTabAs({ + baseTab: activeTab, + as: behavior, + }); + }; break; + } +}); diff --git a/waterfox/browser/components/sidebar/background/handle-misc.js b/waterfox/browser/components/sidebar/background/handle-misc.js new file mode 100644 index 000000000000..8b47caa0a059 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/handle-misc.js @@ -0,0 +1,1161 @@ +/* +# 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'; + +import { + log as internalLogger, + wait, + mapAndFilterUniq, + configs, + loadUserStyleRules, + doProgressively, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Bookmark from '/common/bookmark.js'; +import * as BrowserTheme from '/common/browser-theme.js'; +import * as Constants from '/common/constants.js'; +import * as ContextualIdentities from '/common/contextual-identities.js'; +import * as Permissions from '/common/permissions.js'; +import * as SidebarConnection from '/common/sidebar-connection.js'; +import * as TabsInternalOperation from '/common/tabs-internal-operation.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TabsUpdate from '/common/tabs-update.js'; +import * as TreeBehavior from '/common/tree-behavior.js'; +import * as TSTAPI from '/common/tst-api.js'; + +import { Tab, TreeItem } from '/common/TreeItem.js'; + +import * as Background from './background.js'; +import * as Commands from './commands.js'; +import * as Migration from './migration.js'; +import * as TabsGroup from './tabs-group.js'; +import * as TabsOpen from './tabs-open.js'; +import * as Tree from './tree.js'; + +function log(...args) { + internalLogger('background/handle-misc', ...args); +} + + +/* message observer */ + +// We cannot start listening of messages of browser.runtime.onMessage(External) +// at here and wait processing until promises are resolved like ApiTabsListener +// and BackgroundConnection, because making listeners asynchornous (async +// functions) will break things - those listeners must not return Promise for +// unneeded cases. +// So we simply ignore messages delivered before completely initialized, for now. +// See also: https://github.com/piroor/treestyletab/issues/2200 + +const PHASE_LOADING = 0; +const PHASE_BACKGROUND_INITIALIZED = 1; +const PHASE_BACKGROUND_BUILT = 2; +const PHASE_BACKGROUND_READY = 3; +let mInitializationPhase = PHASE_LOADING; + +Background.onInit.addListener(() => { + mInitializationPhase = PHASE_BACKGROUND_INITIALIZED; +}); + +Background.onBuilt.addListener(() => { + mInitializationPhase = PHASE_BACKGROUND_BUILT; +}); + +Background.onReady.addListener(() => { + mInitializationPhase = PHASE_BACKGROUND_READY; +}); + +if (browser.sidebarAction) + (browser.action || browser.browserAction)?.onClicked.addListener(onToolbarButtonClick); +browser.commands.onCommand.addListener(onShortcutCommand); +browser.runtime.onMessage.addListener(onMessage); +TSTAPI.onMessageExternal.addListener(onMessageExternal); + + +Background.onReady.addListener(() => { + Bookmark.startTracking(); +}); + +Background.onDestroy.addListener(() => { + browser.runtime.onMessage.removeListener(onMessage); + TSTAPI.onMessageExternal.removeListener(onMessageExternal); + if (browser.sidebarAction) + (browser.action || browser.browserAction)?.onClicked.removeListener(onToolbarButtonClick); +}); + + +function onToolbarButtonClick(tab) { + if (mInitializationPhase < PHASE_BACKGROUND_INITIALIZED || + Permissions.requestPostProcess()) { + return; + } + + if (Migration.isInitialStartup()) { + Migration.openInitialStartupPage(); + return; + } + + if (typeof browser.sidebarAction.toggle == 'function') + browser.sidebarAction.toggle(); + else if (SidebarConnection.isSidebarOpen(tab.windowId)) + browser.sidebarAction.close(); + else + browser.sidebarAction.open(); +} + +async function onShortcutCommand(command) { + if (mInitializationPhase < PHASE_BACKGROUND_INITIALIZED) + return; + + let activeTabs = command.tab ? [command.tab] : await browser.tabs.query({ + active: true, + currentWindow: true, + }).catch(ApiTabs.createErrorHandler()); + if (activeTabs.length == 0) + activeTabs = await browser.tabs.query({ + currentWindow: true, + }).catch(ApiTabs.createErrorHandler()); + const activeTab = Tab.get(activeTabs[0].id); + const selectedTabs = activeTab.$TST.multiselected ? Tab.getSelectedTabs(activeTab.windowId) : [activeTab]; + log('onShortcutCommand ', { command, activeTab, selectedTabs }); + + switch (command) { + case '_execute_browser_action': + return; + + case 'reloadTree': + Commands.reloadTree(selectedTabs); + return; + case 'reloadDescendants': + Commands.reloadDescendants(selectedTabs); + return; + case 'toggleMuteTree': + Commands.toggleMuteTree(selectedTabs); + return; + case 'toggleMuteDescendants': + Commands.toggleMuteDescendants(selectedTabs); + return; + case 'closeTree': + Commands.closeTree(selectedTabs); + return; + case 'closeDescendants': + Commands.closeDescendants(selectedTabs); + return; + case 'closeOthers': + Commands.closeOthers(selectedTabs); + return; + case 'toggleSticky': + Commands.toggleSticky(selectedTabs); + return; + case 'collapseTree': + Commands.collapseTree(selectedTabs); + return; + case 'collapseTreeRecursively': + Commands.collapseTree(selectedTabs, { recursively: true }); + return; + case 'collapseAll': + Commands.collapseAll(activeTab.windowId); + return; + case 'expandTree': + Commands.expandTree(selectedTabs); + return; + case 'expandTreeRecursively': + Commands.expandTree(selectedTabs, { recursively: true }); + return; + case 'expandAll': + Commands.expandAll(activeTab.windowId); + return; + case 'bookmarkTree': + Commands.bookmarkTree(selectedTabs); + return; + + case 'newIndependentTab': + Commands.openNewTabAs({ + baseTab: activeTab, + as: Constants.kNEWTAB_OPEN_AS_ORPHAN + }); + return; + case 'newChildTab': + Commands.openNewTabAs({ + baseTab: activeTab, + as: Constants.kNEWTAB_OPEN_AS_CHILD + }); + return; + case 'newChildTabTop': + Commands.openNewTabAs({ + baseTab: activeTab, + as: Constants.kNEWTAB_OPEN_AS_CHILD_TOP + }); + return; + case 'newChildTabEnd': + Commands.openNewTabAs({ + baseTab: activeTab, + as: Constants.kNEWTAB_OPEN_AS_CHILD_END + }); + return; + case 'newSiblingTab': + Commands.openNewTabAs({ + baseTab: activeTab, + as: Constants.kNEWTAB_OPEN_AS_SIBLING + }); + return; + case 'newNextSiblingTab': + Commands.openNewTabAs({ + baseTab: activeTab, + as: Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING + }); + return; + + case 'newContainerTab': + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_SHOW_CONTAINER_SELECTOR, + windowId: activeTab.windowId + }); + return; + + case 'tabMoveUp': + Commands.moveUp(activeTab, { followChildren: false }); + return; + case 'treeMoveUp': + Commands.moveUp(activeTab, { followChildren: true }); + return; + case 'tabMoveDown': + Commands.moveDown(activeTab, { followChildren: false }); + return; + case 'treeMoveDown': + Commands.moveDown(activeTab, { followChildren: true }); + return; + + case 'focusPrevious': + focusPrevious(activeTab); + return; + case 'focusPreviousSilently': + focusPreviousSilently(activeTab); + return; + case 'focusNext': + focusNext(activeTab); + return; + case 'focusNextSilently': + focusNextSilently(activeTab); + return; + case 'focusParent': + TabsInternalOperation.activateTab(activeTab.$TST.parent); + return; + case 'focusParentOrCollapse': + collapseOrFocusToParent(activeTab); + return; + case 'focusFirstChild': + TabsInternalOperation.activateTab(activeTab.$TST.firstChild); + return; + case 'focusFirstChildOrExpand': + expandOrFocusToFirstChild(activeTab); + return; + case 'focusLastChild': + TabsInternalOperation.activateTab(activeTab.$TST.lastChild); + return; + case 'focusPreviousSibling': + TabsInternalOperation.activateTab( + activeTab.$TST.previousSiblingTab || + (activeTab.$TST.parent ? + activeTab.$TST.parent.$TST.lastChild : + Tab.getLastRootTab(activeTab.windowId)) + ); + return; + case 'focusNextSibling': + TabsInternalOperation.activateTab( + activeTab.$TST.nextSiblingTab || + (activeTab.$TST.parent ? + activeTab.$TST.parent.$TST.firstChild : + Tab.getFirstVisibleTab(activeTab.windowId)) + ); + return; + + case 'simulateUpOnTree': + if (SidebarConnection.isOpen(activeTab.windowId)) { + if (configs.faviconizePinnedTabs && + (activeTab.pinned || + activeTab == Tab.getFirstNormalTab(activeTab.windowId))) { + const nextActiveId = await browser.runtime.sendMessage({ + type: Constants.kCOMMAND_GET_ABOVE_TAB, + windowId: activeTab.windowId, + tabId: activeTab.id, + }); + log(`simulateUpOnTree: nextActiveId = ${nextActiveId}`); + const nextActive = ( + Tab.get(nextActiveId) || + Tab.getLastVisibleTab(activeTab.windowId) + ); + TabsInternalOperation.activateTab(nextActive, { + silently: true, + }); + } + else { + focusPreviousSilently(activeTab); + } + } + else { + focusPrevious(activeTab); + } + return; + case 'simulateDownOnTree': + if (SidebarConnection.isOpen(activeTab.windowId)) { + if (configs.faviconizePinnedTabs && + activeTab.pinned) { + const nextActiveId = await browser.runtime.sendMessage({ + type: Constants.kCOMMAND_GET_BELOW_TAB, + windowId: activeTab.windowId, + tabId: activeTab.id, + }); + log(`simulateDownOnTree: nextActiveId = ${nextActiveId}`); + const nextActive = ( + Tab.get(nextActiveId) || + Tab.getFirstNormalTab(activeTab.windowId) + ); + TabsInternalOperation.activateTab(nextActive, { + silently: true, + }); + } + else { + focusNextSilently(activeTab); + } + } + else { + focusNext(activeTab); + } + return; + case 'simulateLeftOnTree': + if (SidebarConnection.isOpen(activeTab.windowId)) { + if (configs.faviconizePinnedTabs && + activeTab.pinned) { + const nextActiveId = await browser.runtime.sendMessage({ + type: Constants.kCOMMAND_GET_LEFT_TAB, + windowId: activeTab.windowId, + tabId: activeTab.id, + }); + log(`simulateLeftOnTree: nextActiveId = ${nextActiveId}`); + TabsInternalOperation.activateTab(Tab.get(nextActiveId), { + silently: true, + }); + } + else if (await isSidebarRightSide(activeTab.windowId)) { + expandOrFocusToFirstChild(activeTab); + } + else { + collapseOrFocusToParent(activeTab); + } + } + else { + focusPrevious(activeTab); + } + return; + case 'simulateRightOnTree': + if (SidebarConnection.isOpen(activeTab.windowId)) { + if (configs.faviconizePinnedTabs && + activeTab.pinned) { + const nextActiveId = await browser.runtime.sendMessage({ + type: Constants.kCOMMAND_GET_RIGHT_TAB, + windowId: activeTab.windowId, + tabId: activeTab.id, + }); + log(`simulateRightOnTree: nextActiveId = ${nextActiveId}`); + TabsInternalOperation.activateTab(Tab.get(nextActiveId), { + silently: true, + }); + } + else if (await isSidebarRightSide(activeTab.windowId)) { + collapseOrFocusToParent(activeTab); + } + else { + expandOrFocusToFirstChild(activeTab); + } + } + else { + focusNext(activeTab); + } + return; + + case 'tabbarUp': + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_SCROLL_TABBAR, + windowId: activeTab.windowId, + by: 'lineup' + }); + return; + case 'tabbarPageUp': + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_SCROLL_TABBAR, + windowId: activeTab.windowId, + by: 'pageup' + }); + return; + case 'tabbarHome': + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_SCROLL_TABBAR, + windowId: activeTab.windowId, + to: 'top' + }); + return; + + case 'tabbarDown': + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_SCROLL_TABBAR, + windowId: activeTab.windowId, + by: 'linedown' + }); + return; + case 'tabbarPageDown': + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_SCROLL_TABBAR, + windowId: activeTab.windowId, + by: 'pagedown' + }); + return; + case 'tabbarEnd': + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_SCROLL_TABBAR, + windowId: activeTab.windowId, + to: 'bottom' + }); + return; + + case 'toggleTreeCollapsed': + if (activeTab.$TST.subtreeCollapsed) + Commands.expandTree(selectedTabs); + else + Commands.collapseTree(selectedTabs); + return; + case 'toggleTreeCollapsedRecursively': + if (activeTab.$TST.subtreeCollapsed) + Commands.expandTree(selectedTabs, { recursively: true }); + else + Commands.collapseTree(selectedTabs, { recursively: true }); + return; + + case 'toggleSubPanel': + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_TOGGLE_SUBPANEL, + windowId: activeTab.windowId + }); + return; + case 'switchSubPanel': + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_SWITCH_SUBPANEL, + windowId: activeTab.windowId + }); + return; + case 'increaseSubPanel': + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_INCREASE_SUBPANEL, + windowId: activeTab.windowId + }); + return; + case 'decreaseSubPanel': + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_DECREASE_SUBPANEL, + windowId: activeTab.windowId + }); + return; + } +} + +function focusPrevious(activeTab) { + const nextActive = activeTab.$TST.nearestVisiblePrecedingTab || + (!SidebarConnection.isOpen(activeTab.windowId) && activeTab.$TST.previousTab) || + Tab.getLastVisibleTab(activeTab.windowId); + TabsInternalOperation.activateTab(nextActive); +} + +function focusPreviousSilently(activeTab) { + const nextActive = activeTab.$TST.nearestVisiblePrecedingTab || + (!SidebarConnection.isOpen(activeTab.windowId) && activeTab.$TST.previousTab) || + Tab.getLastVisibleTab(activeTab.windowId); + TabsInternalOperation.activateTab(nextActive, { + silently: true, + }); +} + +function focusNext(activeTab) { + const nextActive = activeTab.$TST.nearestVisibleFollowingTab || + (!SidebarConnection.isOpen(activeTab.windowId) && activeTab.$TST.nextTab) || + Tab.getFirstVisibleTab(activeTab.windowId); + TabsInternalOperation.activateTab(nextActive); +} + +function focusNextSilently(activeTab) { + const nextActive = activeTab.$TST.nearestVisibleFollowingTab || + (!SidebarConnection.isOpen(activeTab.windowId) && activeTab.$TST.nextTab) || + Tab.getFirstVisibleTab(activeTab.windowId); + TabsInternalOperation.activateTab(nextActive, { + silently: true, + }); +} + +async function isSidebarRightSide(windowId) { + const position = await browser.runtime.sendMessage({ + type: Constants.kCOMMAND_GET_SIDEBAR_POSITION, + windowId, + }); + return position == Constants.kTABBAR_POSITION_RIGHT; +} + +function collapseOrFocusToParent(activeTab) { + if (!activeTab.$TST.subtreeCollapsed && activeTab.$TST.hasChild) + Commands.collapseTree(activeTab); + else + TabsInternalOperation.activateTab(activeTab.$TST.parent); +} + +function expandOrFocusToFirstChild(activeTab) { + if (activeTab.$TST.subtreeCollapsed && activeTab.$TST.hasChild) + Commands.expandTree(activeTab); + else + TabsInternalOperation.activateTab(activeTab.$TST.firstChild, { + silently: true, + }); +} + +// This must be synchronous and return Promise on demando, to avoid +// blocking to other listeners. +function onMessage(message, sender) { + if (mInitializationPhase < PHASE_BACKGROUND_BUILT || + !message || + typeof message.type != 'string' || + message.type.indexOf('ws:') != 0) + return; + + //log('onMessage: ', message, sender); + switch (message.type) { + case Constants.kCOMMAND_GET_INSTANCE_ID: + return Promise.resolve(Background.instanceId); + + case Constants.kCOMMAND_RELOAD: + return Background.reload({ all: message.all }); + + case Constants.kCOMMAND_REQUEST_UNIQUE_ID: + return (async () => { + if (!Tab.get(message.tabId)) + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + return tab ? tab.$TST.promisedUniqueId : {}; + })(); + + case Constants.kCOMMAND_GET_THEME_DECLARATIONS: + return browser.theme.getCurrent().then(theme => BrowserTheme.generateThemeDeclarations(theme)); + + case Constants.kCOMMAND_GET_CONTEXTUAL_IDENTITIES_COLOR_INFO: + return Promise.resolve(ContextualIdentities.getColorInfo()); + + case Constants.kCOMMAND_GET_CONFIG_VALUE: + if (Array.isArray(message.keys)) { + const values = {}; + for (const key of message.keys) { + values[key] = configs[key]; + } + return Promise.resolve(values); + } + return Promise.resolve(configs[message.key]); + + case Constants.kCOMMAND_SET_CONFIG_VALUE: + return Promise.resolve(configs[message.key] = message.value); + + case Constants.kCOMMAND_GET_USER_STYLE_RULES: + return Promise.resolve(loadUserStyleRules()); + + case Constants.kCOMMAND_PING_TO_BACKGROUND: // return tabs as the pong, to optimizie further initialization tasks in the sidebar + TabsUpdate.completeLoadingTabs(message.windowId); // don't wait here for better perfomance + return Promise.resolve(TabsStore.windows.get(message.windowId).export(true)); + + case Constants.kCOMMAND_PULL_TABS: + if (message.windowId) { + TabsUpdate.completeLoadingTabs(message.windowId); // don't wait here for better perfomance + return Promise.resolve(TabsStore.windows.get(message.windowId).export(true).tabs); + } + return Promise.resolve(message.tabIds.map(id => { + const tab = Tab.get(id); + return tab?.$TST.export(true); + })); + + case Constants.kCOMMAND_PULL_TABS_ORDER: + return Promise.resolve(TabsStore.windows.get(message.windowId).order); + + case Constants.kCOMMAND_PULL_TREE_STRUCTURE: + return (async () => { + while (mInitializationPhase < PHASE_BACKGROUND_READY) { + await wait(10); + } + const structure = TreeBehavior.getTreeStructureFromTabs( + message.windowId ? + Tab.getAllTabs(message.windowId) : + message.tabIds.map(id => Tab.get(id)) + ); + return { structure }; + })(); + + case Constants.kCOMMAND_NOTIFY_PERMISSIONS_GRANTED: + return (async () => { + const grantedPermission = JSON.stringify(message.permissions); + if (grantedPermission == JSON.stringify(Permissions.ALL_URLS)) { + const tabs = await browser.tabs.query({}).catch(ApiTabs.createErrorHandler()); + await Tab.waitUntilTracked(tabs.map(tab => tab.id)); + for (const tab of tabs) { + Background.tryStartHandleAccelKeyOnTab(Tab.get(tab.id)); + } + } + else if (grantedPermission == JSON.stringify(Permissions.BOOKMARKS)) { + Migration.migrateBookmarkUrls(); + Bookmark.startTracking(); + } + })(); + + case Constants.kCOMMAND_SIMULATE_SIDEBAR_MESSAGE: { + SidebarConnection.onMessage.dispatch(message.message.windowId, message.message); + }; break; + + case Constants.kCOMMAND_CONFIRM_TO_CLOSE_TABS: + log('kCOMMAND_CONFIRM_TO_CLOSE_TABS: ', { message }); + return Background.confirmToCloseTabs(message.tabs, message); + + default: + if (TSTAPI.INTERNAL_CALL_PREFIX_MATCHER.test(message.type)) { + return onMessageExternal({ + ...message, + type: message.type.replace(TSTAPI.INTERNAL_CALL_PREFIX_MATCHER, ''), + }, sender); + } + break; + } +} + +// This must be synchronous and return Promise on demando, to avoid +// blocking to other listeners. +function onMessageExternal(message, sender) { + if (mInitializationPhase < PHASE_BACKGROUND_INITIALIZED) + return; + + switch (message.type) { + case TSTAPI.kGET_VERSION: + return Promise.resolve(browser.runtime.getManifest().version); + + case TSTAPI.kGET_TREE: + return (async () => { + const tabs = await (message.rendered ? + TSTAPI.getTargetRenderedTabs(message, sender) : + TSTAPI.getTargetTabs(message, sender)); + const cache = {}; + const treeItems = Array.from(tabs, tab => TSTAPI.exportTab(tab, { + interval: message.interval, + cache, + })); + const result = TSTAPI.formatTabResult( + treeItems, + { + ...message, + // This must return an array of root tabs if just the window id is specified. + // See also: https://github.com/piroor/treestyletab/issues/2763 + ...((message.window || message.windowId) && !message.tab && !message.tabs ? { tab: '*' } : {}) + }, + sender.id + ); + TSTAPI.clearCache(cache); + return result; + })(); + + case TSTAPI.kGET_LIGHT_TREE: + return (async () => { + const tabs = await (message.rendered ? + TSTAPI.getTargetRenderedTabs(message, sender) : + TSTAPI.getTargetTabs(message, sender)); + const cache = {}; + const treeItems = Array.from(tabs, tab => TSTAPI.exportTab(tab, { + light: true, + interval: message.interval, + cache, + })); + const result = TSTAPI.formatTabResult( + treeItems, + { + ...message, + // This must return an array of root tabs if just the window id is specified. + // See also: https://github.com/piroor/treestyletab/issues/2763 + ...((message.window || message.windowId) && !message.tab && !message.tabs ? { tab: '*' } : {}) + }, + sender.id + ); + TSTAPI.clearCache(cache); + return result; + })(); + + case TSTAPI.kSTICK_TAB: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + await doProgressively( + tabs, + tab => Commands.toggleSticky(tab, true), + message.interval + ); + return true; + })(); + + case TSTAPI.kUNSTICK_TAB: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + await doProgressively( + tabs, + tab => Commands.toggleSticky(tab, false), + message.interval + ); + return true; + })(); + + case TSTAPI.kTOGGLE_STICKY_STATE: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + let firstTabIsSticky = undefined; + await doProgressively( + tabs, + tab => { + if (firstTabIsSticky === undefined) + firstTabIsSticky = tab.$TST.sticky; + Commands.toggleSticky(tab, !firstTabIsSticky); + }, + message.interval + ); + return true; + })(); + + case TSTAPI.kCOLLAPSE_TREE: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + await doProgressively( + tabs, + tab => Commands.collapseTree(tab, { + recursively: !!message.recursively + }), + message.interval + ); + return true; + })(); + + case TSTAPI.kEXPAND_TREE: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + await doProgressively( + tabs, + tab => Commands.expandTree(tab, { + recursively: !!message.recursively + }), + message.interval + ); + return true; + })(); + + case TSTAPI.kTOGGLE_TREE_COLLAPSED: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + await doProgressively( + tabs, + tab => Tree.collapseExpandSubtree(tab, { + collapsed: !tab.$TST.subtreeCollapsed, + broadcast: true + }), + message.interval + ); + return true; + })(); + + case TSTAPI.kATTACH: + return (async () => { + await Tab.waitUntilTracked([ + message.child, + message.parent, + message.insertBefore, + message.insertAfter + ]); + const child = Tab.get(message.child); + const parent = Tab.get(message.parent); + if (!child || + !parent || + child.windowId != parent.windowId) + return false; + await Tree.attachTabTo(child, parent, { + broadcast: true, + insertBefore: Tab.get(message.insertBefore), + insertAfter: Tab.get(message.insertAfter) + }); + if (child.$TST.collapsed && + !parent.$TST.collapsed && + !parent.$TST.subtreeCollapsed) { + await Tree.collapseExpandTabAndSubtree(child, { + collapsed: false, + bradcast: true + }); + } + return true; + })(); + + case TSTAPI.kDETACH: + return (async () => { + await Tab.waitUntilTracked(message.tab); + const tab = Tab.get(message.tab); + if (!tab) + return false; + await Tree.detachTab(tab, { + broadcast: true + }); + if (tab.$TST.collapsed) { + await Tree.collapseExpandTabAndSubtree(tab, { + collapsed: false, + bradcast: true + }); + } + return true; + })(); + + case TSTAPI.kINDENT: + case TSTAPI.kDEMOTE: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + const results = await doProgressively( + tabs, + tab => Commands.indent(tab, message), + message.interval + ); + return TSTAPI.formatResult(results, message); + })(); + + case TSTAPI.kOUTDENT: + case TSTAPI.kPROMOTE: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + const results = await doProgressively( + tabs, + tab => Commands.outdent(tab, message), + message.interval + ); + return TSTAPI.formatResult(results, message); + })(); + + case TSTAPI.kMOVE_UP: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + const results = await doProgressively( + tabs, + tab => Commands.moveUp(tab, message), + message.interval + ); + return TSTAPI.formatResult(results, message); + })(); + + case TSTAPI.kMOVE_TO_START: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + await Commands.moveTabsToStart(Array.from(tabs)); + return true; + })(); + + case TSTAPI.kMOVE_DOWN: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + const results = await doProgressively( + tabs, + tab => Commands.moveDown(tab, message), + message.interval + ); + return TSTAPI.formatResult(results, message); + })(); + + case TSTAPI.kMOVE_TO_END: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + await Commands.moveTabsToEnd(Array.from(tabs)); + return true; + })(); + + case TSTAPI.kMOVE_BEFORE: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + const results = await doProgressively( + tabs, + tab => Commands.moveBefore(tab, message), + message.interval + ); + return TSTAPI.formatResult(results, message); + })(); + + case TSTAPI.kMOVE_AFTER: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + const results = await doProgressively( + tabs, + tab => Commands.moveAfter(tab, message), + message.interval + ); + return TSTAPI.formatResult(results, message); + })(); + + case TSTAPI.kFOCUS: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + const tabsArray = await doProgressively( + tabs, + tab => { + TabsInternalOperation.activateTab(tab, { + silently: message.silently + }); + return tab; + }, + message.interval + ); + return TSTAPI.formatResult(tabsArray.map(() => true), message); + })(); + + case TSTAPI.kCREATE: + return (async () => { + const windowId = message.params.windowId; + const win = TabsStore.windows.get(windowId); + if (!win) + throw new Error(`invalid windowId ${windowId}: it must be valid window id`); + win.bypassTabControlCount++; + const tab = await TabsOpen.openURIInTab(message.params, { windowId }); + return TSTAPI.exportTab(tab, { addonId: sender.id }); + })(); + + case TSTAPI.kDUPLICATE: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + let behavior = configs.autoAttachOnDuplicated; + switch (String(message.as || 'sibling').toLowerCase()) { + case 'child': + behavior = behavior == Constants.kNEWTAB_OPEN_AS_CHILD_TOP ? + Constants.kNEWTAB_OPEN_AS_CHILD_TOP : + behavior == Constants.kNEWTAB_OPEN_AS_CHILD_END ? + Constants.kNEWTAB_OPEN_AS_CHILD_END : + Constants.kNEWTAB_OPEN_AS_CHILD; + break; + case 'first-child': + behavior = Constants.kNEWTAB_OPEN_AS_CHILD_TOP; + break; + case 'last-child': + behavior = Constants.kNEWTAB_OPEN_AS_CHILD_END; + break; + case 'sibling': + behavior = Constants.kNEWTAB_OPEN_AS_SIBLING; + break; + case 'nextsibling': + behavior = Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING; + break; + case 'orphan': + behavior = Constants.kNEWTAB_OPEN_AS_ORPHAN; + break; + default: + break; + } + const tabsArray = await doProgressively( + tabs, + async tab => { + return Commands.duplicateTab(tab, { + destinationWindowId: tab.windowId, + behavior, + multiselected: false + }); + }, + message.interval + ); + return TSTAPI.formatResult(tabsArray.map(() => true), message); + })(); + + case TSTAPI.kGROUP_TABS: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + const temporaryStateParams = (message.temporary && !message.temporaryAggressive) ? + { + temporary: true, + temporaryAggressive: false, + } : + (!message.temporary && message.temporaryAggressive) ? + { + temporary: false, + temporaryAggressive: true, + } : + (message.temporaryAggressive === false && message.temporary === false) ? + { + temporary: false, + temporaryAggressive: false, + } : + {}; + const tab = await TabsGroup.groupTabs(Array.from(tabs), { + title: message.title, + broadcast: true, + ...TabsGroup.temporaryStateParams(configs.groupTabTemporaryStateForAPI), + ...temporaryStateParams, + }); + if (!tab) + return null; + return TSTAPI.exportTab(tab, { addonId: sender.id }); + })(); + + case TSTAPI.kOPEN_IN_NEW_WINDOW: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + const windowId = await Commands.openTabsInWindow(Array.from(tabs), { + multiselected: false + }); + return windowId; + })(); + + case TSTAPI.kREOPEN_IN_CONTAINER: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + const reopenedTabs = await Commands.reopenInContainer( + Array.from(tabs), + message.containerId || 'firefox-default' + ); + const cache = {}; + const result = await TSTAPI.formatTabResult( + reopenedTabs.map(tab => TSTAPI.exportTab(tab, { + interval: message.interval, + addonId: sender.id, + cache, + })), + message + ); + TSTAPI.clearCache(cache); + return result; + })(); + + case TSTAPI.kGET_TREE_STRUCTURE: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + return Promise.resolve(TreeBehavior.getTreeStructureFromTabs(Array.from(tabs))); + })(); + + case TSTAPI.kSET_TREE_STRUCTURE: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + await Tree.applyTreeStructureToTabs( + Array.from(tabs), + message.structure, + { broadcast: true } + ); + return Promise.resolve(true); + })(); + + case TSTAPI.kADD_TAB_STATE: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + let states = message.state || message.states; + if (!Array.isArray(states)) + states = [states]; + states = mapAndFilterUniq(states, state => state && String(state) || undefined); + if (states.length == 0) + return true; + const tabsArray = await doProgressively( + tabs, + tab => { + for (const state of states) { + tab.$TST.addState(state); + } + return tab; + }, + message.interval + ); + Tab.broadcastState(tabsArray, { + add: states + }); + return true; + })(); + + case TSTAPI.kREMOVE_TAB_STATE: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + let states = message.state || message.states; + if (!Array.isArray(states)) + states = [states]; + states = mapAndFilterUniq(states, state => state && String(state) || undefined); + if (states.length == 0) + return true; + const tabsArray = await doProgressively( + tabs, + tab => { + for (const state of states) { + tab.$TST.removeState(state); + } + return tab; + }, + message.interval + ); + Tab.broadcastState(tabsArray, { + remove: states + }); + return true; + })(); + + case TSTAPI.kGRANT_TO_REMOVE_TABS: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + configs.grantedRemovingTabIds = mapAndFilterUniq(configs.grantedRemovingTabIds.concat(tabs), tab => { + tab = TabsStore.ensureLivingItem(tab); + return tab?.id || undefined; + }); + return true; + })(); + + case TSTAPI.kSTART_CUSTOM_DRAG: + return (async () => { + SidebarConnection.sendMessage({ + type: Constants.kNOTIFY_TAB_MOUSEDOWN_EXPIRED, + windowId: message.windowId || (await browser.windows.getLastFocused({ populate: false }).catch(ApiTabs.createErrorHandler())).id, + button: message.button || 0 + }); + })(); + + case TSTAPI.kOPEN_ALL_BOOKMARKS_WITH_STRUCTURE: + return Commands.openAllBookmarksWithStructure( + message.id || message.bookmarkId, + { discarded: message.discarded } + ); + + case TSTAPI.kSET_TOOLTIP_TEXT: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + for (const tab of tabs) { + tab.$TST.registerTooltipText(sender.id, message.text || '', !!message.force); + } + return true; + })(); + + case TSTAPI.kCLEAR_TOOLTIP_TEXT: + return (async () => { + const tabs = await TSTAPI.getTargetTabs(message, sender); + for (const tab of tabs) { + tab.$TST.unregisterTooltipText(sender.id); + } + return true; + })(); + + case TSTAPI.kREGISTER_AUTO_STICKY_STATES: + TreeItem.registerAutoStickyState(sender.id, message.state || message.states); + break; + + case TSTAPI.kUNREGISTER_AUTO_STICKY_STATES: + TreeItem.unregisterAutoStickyState(sender.id, message.state || message.states); + break; + } +} + +Tab.onStateChanged.addListener((tab, state, added) => { + switch (state) { + case Constants.kTAB_STATE_STICKY: + if (TSTAPI.hasListenerForMessageType(TSTAPI.kNOTIFY_TAB_STICKY_STATE_CHANGED)) { + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_TAB_STICKY_STATE_CHANGED, + tab, + sticky: !!added, + }, { tabProperties: ['tab'] }).catch(_error => {}); + } + break; + } +}); diff --git a/waterfox/browser/components/sidebar/background/handle-moved-tabs.js b/waterfox/browser/components/sidebar/background/handle-moved-tabs.js new file mode 100644 index 000000000000..ab99fe996f60 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/handle-moved-tabs.js @@ -0,0 +1,296 @@ +/* +# 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'; + +import { + log as internalLogger, + dumpTab, + configs, + wait, + isMacOS, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from '/common/constants.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TreeBehavior from '/common/tree-behavior.js'; +import * as TSTAPI from '/common/tst-api.js'; + +import { Tab } from '/common/TreeItem.js'; + +import * as Commands from './commands.js'; +import * as NativeTabGroups from './native-tab-groups.js'; +import * as TabsGroup from './tabs-group.js'; +import * as Tree from './tree.js'; +import * as TreeStructure from './tree-structure.js'; + +function log(...args) { + internalLogger('background/handle-moved-tabs', ...args); +} +function logApiTabs(...args) { + internalLogger('common/api-tabs', ...args); +} + + +let mMaybeTabMovingByShortcut = false; + + +Tab.onCreated.addListener((tab, info = {}) => { + if (!info.mayBeReplacedWithContainer && + (info.duplicated || + info.restored || + info.skipFixupTree || + // do nothing for already attached tabs + (tab.openerTabId && + tab.$TST.parent == Tab.get(tab.openerTabId)))) { + log('skip to fixup tree for replaced/duplicated/restored tab ', tab, info); + return; + } + // if the tab is opened inside existing tree by someone, we must fixup the tree. + if (!(info.positionedBySelf || + info.movedBySelfWhileCreation) && + (tab.$TST.nearestCompletelyOpenedNormalFollowingTab || + tab.$TST.nearestCompletelyOpenedNormalPrecedingTab || + (info.treeForActionDetection?.target && + (info.treeForActionDetection.target.next || + info.treeForActionDetection.target.previous)))) { + tryFixupTreeForInsertedTab(tab, { + toIndex: tab.index, + fromIndex: Tab.getLastTab(tab.windowId).index, + treeForActionDetection: info.treeForActionDetection, + isTabCreating: true + }); + } + else { + log('no need to fixup tree for newly created tab ', tab, info); + } +}); + +Tab.onMoving.addListener((tab, moveInfo) => { + // avoid TabMove produced by browser.tabs.insertRelatedAfterCurrent=true or something. + const win = TabsStore.windows.get(tab.windowId); + const isNewlyOpenedTab = win.openingTabs.has(tab.id); + const positionControlled = configs.insertNewChildAt != Constants.kINSERT_NO_CONTROL; + if (!isNewlyOpenedTab || + !positionControlled || + moveInfo.byInternalOperation || + moveInfo.alreadyMoved || + !moveInfo.movedInBulk) + return true; + + // if there is no valid opener, it can be a restored initial tab in a restored window + // and can be just moved as a part of window restoration process. + if (!tab.$TST.openerTab) + return true; + + log('onTabMove for new child tab: move back '+moveInfo.toIndex+' => '+moveInfo.fromIndex); + moveBack(tab, moveInfo); + return false; +}); + +async function tryFixupTreeForInsertedTab(tab, moveInfo = {}) { + const internalGroupMoveCount = NativeTabGroups.internallyMovingNativeTabGroups.get(tab.groupId); + if (internalGroupMoveCount) { + log('ignore internal move of tab groups ', internalGroupMoveCount); + return; + } + const parentTabOperationBehavior = TreeBehavior.getParentTabOperationBehavior(tab, { + context: Constants.kPARENT_TAB_OPERATION_CONTEXT_MOVE, + ...moveInfo, + }); + log('tryFixupTreeForInsertedTab ', { + tab: tab.id, + parentTabOperationBehavior, + moveInfo, + childIds: tab.$TST.childIds, + parentId: tab.$TST.parentId, + }); + if (!moveInfo.isTabCreating && + parentTabOperationBehavior != Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE) { + if (tab.$TST.hasChild) + tab.$TST.temporaryMetadata.set('childIdsBeforeMoved', tab.$TST.childIds.slice(0)); + tab.$TST.temporaryMetadata.set('parentIdBeforeMoved', tab.$TST.parentId); + const replacedGroupTab = (parentTabOperationBehavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB) ? + await TabsGroup.tryReplaceTabWithGroup(tab, { insertBefore: tab.$TST.firstChild }) : + null; + if (!replacedGroupTab && tab.$TST.hasChild) { + if (tab.$TST.isGroupTab) + await TabsGroup.clearTemporaryState(tab); + await Tree.detachAllChildren(tab, { + behavior: parentTabOperationBehavior, + nearestFollowingRootTab: tab.$TST.firstChild.$TST.nearestFollowingRootTab, + broadcast: true + }); + } + if (tab.$TST.parentId) + await Tree.detachTab(tab, { + broadcast: true + }); + // Pinned tab is moved at first, so Tab.onPinned handler cannot know tree information + // before the pinned tab was moved. Thus we cache tree information for the handler. + wait(100).then(() => { + tab.$TST.temporaryMetadata.delete('childIdsBeforeMoved'); + tab.$TST.temporaryMetadata.delete('parentIdBeforeMoved'); + }); + } + + log('The tab can be placed inside existing tab unexpectedly, so now we are trying to fixup tree.'); + const action = Tree.detectTabActionFromNewPosition(tab, { + isMovingByShortcut: mMaybeTabMovingByShortcut, + ...moveInfo, + }); + log(' => action: ', action); + if (!action.action) { + log('no action'); + return; + } + + // When multiple tabs are moved at once by outside of TST (e.g. moving of multiselected tabs) + // Tree.detectTabActionFromNewPosition() may be called for other tabs asynchronously + // before this operation finishes. Thus we need to memorize the calculated "parent" + // and Tree.detectTabActionFromNewPosition() will use it. + if (action.parent) + tab.$TST.temporaryMetadata.set('goingToBeAttachedTo', action.parent); + + // notify event to helper addons with action and allow or deny + const cache = {}; + const allowed = await TSTAPI.tryOperationAllowed( + TSTAPI.kNOTIFY_TRY_FIXUP_TREE_ON_TAB_MOVED, + { + tab, + fromIndex: moveInfo.fromIndex, + toIndex: moveInfo.toIndex, + action: action.action, + parent: action.parent, + insertBefore: action.insertBefore, + insertAfter: action.insertAfter, + }, + { tabProperties: ['tab', 'parent', 'insertBefore', 'insertAfter'], cache } + ); + TSTAPI.clearCache(cache); + + if (!allowed) { + log('no action - canceled by a helper addon'); + } + else { + log('action: ', action); + switch (action.action) { + case 'invalid': + moveBack(tab, moveInfo); + break; + + default: + log('tryFixupTreeForInsertedTab: apply action for unattached tab: ', tab, action); + await action.apply(); + break; + } + } + + if (tab.$TST.temporaryMetadata.get('goingToBeAttachedTo') == action.parent) + tab.$TST.temporaryMetadata.delete('goingToBeAttachedTo'); +} + +function reserveToEnsureRootTabVisible(tab) { + reserveToEnsureRootTabVisible.tabIds.add(tab.id); + if (reserveToEnsureRootTabVisible.reserved) + clearTimeout(reserveToEnsureRootTabVisible.reserved); + reserveToEnsureRootTabVisible.reserved = setTimeout(() => { + delete reserveToEnsureRootTabVisible.reserved; + const tabs = Array.from(reserveToEnsureRootTabVisible.tabIds, Tab.get); + reserveToEnsureRootTabVisible.tabIds.clear(); + for (const tab of tabs) { + if (!tab.$TST || + tab.$TST.parent || + !tab.$TST.collapsed) + continue; + Tree.collapseExpandTabAndSubtree(tab, { + collapsed: false, + broadcast: true + }); + } + }, 150); +} +reserveToEnsureRootTabVisible.tabIds = new Set(); + +Tab.onMoved.addListener((tab, moveInfo = {}) => { + if (moveInfo.byInternalOperation || + !moveInfo.movedInBulk || + tab.$TST.duplicating) { + log('internal move'); + tab.$TST.nativeTabGroup?.$TST.reindex(); + } + else { + log('process moved tab'); + tryFixupTreeForInsertedTab(tab, moveInfo).then(() => { + tab.$TST.nativeTabGroup?.$TST.reindex(); + }); + } + reserveToEnsureRootTabVisible(tab); +}); + +function onMessage(message, _sender) { + if (!message || + typeof message.type != 'string') + return; + + //log('onMessage: ', message, sender); + switch (message.type) { + case Constants.kNOTIFY_TAB_MOUSEDOWN: + mMaybeTabMovingByShortcut = false; + break; + + case Constants.kCOMMAND_NOTIFY_MAY_START_TAB_SWITCH: + if (message.modifier != (configs.accelKey || (isMacOS() ? 'meta' : 'control'))) + return; + mMaybeTabMovingByShortcut = true; + break; + + case Constants.kCOMMAND_NOTIFY_MAY_END_TAB_SWITCH: + if (message.modifier != (configs.accelKey || (isMacOS() ? 'meta' : 'control'))) + return; + mMaybeTabMovingByShortcut = false; + break; + } +} +browser.runtime.onMessage.addListener(onMessage); + + +Commands.onMoveUp.addListener(async tab => { + await tryFixupTreeForInsertedTab(tab, { + toIndex: tab.index, + fromIndex: tab.index + 1, + }); +}); + +Commands.onMoveDown.addListener(async tab => { + await tryFixupTreeForInsertedTab(tab, { + toIndex: tab.index, + fromIndex: tab.index - 1, + }); +}); + +TreeStructure.onTabAttachedFromRestoredInfo.addListener((tab, moveInfo) => { tryFixupTreeForInsertedTab(tab, moveInfo); }); + +function moveBack(tab, moveInfo) { + log('Move back tab from unexpected move: ', dumpTab(tab), moveInfo); + const id = tab.id; + const win = TabsStore.windows.get(tab.windowId); + const index = moveInfo.fromIndex; + win.internalMovingTabs.set(id, index); + logApiTabs(`handle-moved-tabs:moveBack: browser.tabs.move() `, tab.id, { + windowId: moveInfo.windowId, + index: moveInfo.fromIndex + }); + // Because we need to use the raw "fromIndex" directly, + // we cannot use TabsMove.moveTabInternallyBefore/After here. + return browser.tabs.move(tab.id, { + windowId: moveInfo.windowId, + index: moveInfo.fromIndex + }).catch(ApiTabs.createErrorHandler(e => { + if (win.internalMovingTabs.get(id) == index) + win.internalMovingTabs.delete(id); + ApiTabs.handleMissingTabError(e); + })); +} diff --git a/waterfox/browser/components/sidebar/background/handle-new-tabs.js b/waterfox/browser/components/sidebar/background/handle-new-tabs.js new file mode 100644 index 000000000000..1e223e809117 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/handle-new-tabs.js @@ -0,0 +1,598 @@ +/* +# 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'; + +import { + log as internalLogger, + dumpTab, + configs, + isFirefoxViewTab, +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; +import * as TabsInternalOperation from '/common/tabs-internal-operation.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TreeBehavior from '/common/tree-behavior.js'; +import * as TSTAPI from '/common/tst-api.js'; + +import { Tab } from '/common/TreeItem.js'; + +import * as TabsMove from './tabs-move.js'; +import * as TabsOpen from './tabs-open.js'; +import * as Tree from './tree.js'; + +function log(...args) { + internalLogger('background/handle-new-tabs', ...args); +} + + +Tab.onBeforeCreate.addListener(async (tab, info) => { + const activeTab = info.activeTab || Tab.getActiveTab(tab.windowId); + + // Special case, when all these conditions are true: + // 1) A new blank tab is configured to be opened as a child of the active tab. + // 2) The active tab is pinned. + // 3) Tabs opened from a pinned parent are configured to be placed near the + // opener pinned tab. + // then we fakely attach the new blank tab to the active pinned tab. + // See also https://github.com/piroor/treestyletab/issues/3296 + const shouldAttachToPinnedOpener = ( + !tab.openerTabId && + !tab.pinned && + tab.$TST.isNewTabCommandTab && + Constants.kCONTROLLED_NEWTAB_POSITION.has(configs.autoAttachOnNewTabCommand) && + ( + (activeTab?.pinned && + Constants.kCONTROLLED_INSERTION_POSITION.has(configs.insertNewTabFromPinnedTabAt)) || + (isFirefoxViewTab(activeTab) && + Constants.kCONTROLLED_INSERTION_POSITION.has(configs.insertNewTabFromFirefoxViewAt)) + ) + ); + if (shouldAttachToPinnedOpener) + tab.openerTabId = activeTab.id; +}); + +// this should return false if the tab is / may be moved while processing +Tab.onCreating.addListener((tab, info = {}) => { + if (info.duplicatedInternally) + return true; + + log('Tabs.onCreating ', dumpTab(tab), tab.openerTabId, info); + + const activeTab = info.activeTab || Tab.getActiveTab(tab.windowId); + const opener = tab.$TST.openerTab; + if (opener) { + tab.$TST.setAttribute(Constants.kPERSISTENT_ORIGINAL_OPENER_TAB_ID, opener.$TST.uniqueId.id); + if (!info.bypassTabControl) + TabsStore.addToBeGroupedTab(tab); + } + else { + let dontMove = false; + if (!info.maybeOrphan && + !info.bypassTabControl && + activeTab && + !info.restored) { + let autoAttachBehavior = configs.autoAttachOnNewTabCommand; + if (tab.$TST.nextTab && + activeTab == tab.$TST.previousTab) { + // New tab opened with browser.tabs.insertAfterCurrent=true may have + // next tab. In this case the tab is expected to be placed next to the + // active tab always, so we should change the behavior specially. + // See also: + // https://github.com/piroor/treestyletab/issues/2054 + // https://github.com/piroor/treestyletab/issues/2194#issuecomment-505272940 + dontMove = true; + switch (autoAttachBehavior) { + case Constants.kNEWTAB_OPEN_AS_ORPHAN: + case Constants.kNEWTAB_OPEN_AS_SIBLING: + case Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING: + if (activeTab.$TST.hasChild) + autoAttachBehavior = Constants.kNEWTAB_OPEN_AS_CHILD; + else + autoAttachBehavior = Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING; + break; + + case Constants.kNEWTAB_OPEN_AS_CHILD: + default: + break; + } + } + if (tab.$TST.isNewTabCommandTab) { + if (!info.positionedBySelf) { + log('behave as a tab opened by new tab command'); + return handleNewTabFromActiveTab(tab, { + activeTab, + autoAttachBehavior, + dontMove, + openedWithCookieStoreId: info.openedWithCookieStoreId, + inheritContextualIdentityMode: configs.inheritContextualIdentityToChildTabMode, + context: TSTAPI.kNEWTAB_CONTEXT_NEWTAB_COMMAND, + }).then(moved => !moved); + } + return false; + } + else if (activeTab != tab) { + tab.$TST.temporaryMetadata.set('possibleOpenerTab', activeTab.id); + } + if (!info.fromExternal) + tab.$TST.temporaryMetadata.set('isNewTab', true); + } + if (info.fromExternal && + !info.bypassTabControl) { + log('behave as a tab opened from external application'); + // we may need to reopen the tab with loaded URL + if (configs.inheritContextualIdentityToTabsFromExternalMode != Constants.kCONTEXTUAL_IDENTITY_DEFAULT) + tab.$TST.temporaryMetadata.set('fromExternal', true); + return notifyToTryHandleNewTab(tab, { + context: TSTAPI.kNEWTAB_CONTEXT_FROM_EXTERNAL, + activeTab, + }).then(allowed => { + if (!allowed) { + log(' => handling is canceled by someone'); + return true; + } + return Tree.behaveAutoAttachedTab(tab, { + baseTab: activeTab, + behavior: configs.autoAttachOnOpenedFromExternal, + dontMove, + broadcast: true + }).then(moved => !moved); + }); + } + log('behave as a tab opened with any URL ', tab.title, tab.url); + if (!info.restored && + !info.positionedBySelf && + !info.bypassTabControl && + configs.autoAttachOnAnyOtherTrigger != Constants.kNEWTAB_DO_NOTHING) { + if (configs.inheritContextualIdentityToTabsFromAnyOtherTriggerMode != Constants.kCONTEXTUAL_IDENTITY_DEFAULT) + tab.$TST.temporaryMetadata.set('anyOtherTrigger', true); + log('controlled as a new tab from other unknown trigger'); + return notifyToTryHandleNewTab(tab, { + context: TSTAPI.kNEWTAB_CONTEXT_UNKNOWN, + activeTab, + }).then(allowed => { + if (!allowed) { + log(' => handling is canceled by someone'); + return true; + } + return Tree.behaveAutoAttachedTab(tab, { + baseTab: activeTab, + behavior: configs.autoAttachOnAnyOtherTrigger, + dontMove, + broadcast: true + }).then(moved => !moved); + }); + } + if (info.positionedBySelf) + tab.$TST.temporaryMetadata.set('positionedBySelf', true); + return true; + } + + log(`opener: ${dumpTab(opener)}, positionedBySelf = ${info.positionedBySelf}`); + if (!info.bypassTabControl && + opener && + (opener.pinned || isFirefoxViewTab(opener)) && + opener.windowId == tab.windowId) { + return handleTabsFromPinnedOpener(tab, opener, { activeTab }).then(moved => !moved); + } + else if (!info.maybeOrphan || info.bypassTabControl) { + if (info.fromExternal && + !info.bypassTabControl && + configs.inheritContextualIdentityToTabsFromExternalMode != Constants.kCONTEXTUAL_IDENTITY_DEFAULT) + tab.$TST.temporaryMetadata.set('fromExternal', true); + return notifyToTryHandleNewTab(tab, { + context: info.fromExternal && !info.bypassTabControl ? + TSTAPI.kNEWTAB_CONTEXT_FROM_EXTERNAL : + info.duplicated ? + TSTAPI.kNEWTAB_CONTEXT_DUPLICATED : + TSTAPI.kNEWTAB_CONTEXT_WITH_OPENER, + activeTab, + openerTab: opener, + }).then(allowed => { + if (!allowed) { + log(' => handling is canceled by someone'); + return true; + } + const behavior = info.fromExternal && !info.bypassTabControl ? + configs.autoAttachOnOpenedFromExternal : + info.duplicated ? + configs.autoAttachOnDuplicated : + configs.autoAttachOnOpenedWithOwner; + return Tree.behaveAutoAttachedTab(tab, { + baseTab: opener, + behavior, + dontMove: info.positionedBySelf || info.mayBeReplacedWithContainer, + broadcast: true + }).then(moved => !moved); + }); + } + return true; +}); + +async function notifyToTryHandleNewTab(tab, { context, activeTab, openerTab } = {}) { + const cache = {}; + const result = TSTAPI.tryOperationAllowed( + TSTAPI.kNOTIFY_TRY_HANDLE_NEWTAB, + { tab, + activeTab, + openerTab, + context }, + { tabProperties: ['tab', 'activeTab', 'openerTab'], cache } + ); + TSTAPI.clearCache(cache); + return result; +} + +async function handleNewTabFromActiveTab(tab, { url, activeTab, autoAttachBehavior, dontMove, openedWithCookieStoreId, inheritContextualIdentityMode, context } = {}) { + log('handleNewTabFromActiveTab: activeTab = ', dumpTab(activeTab), { url, activeTab, autoAttachBehavior, dontMove, inheritContextualIdentityMode, context }); + if (activeTab && + activeTab.$TST.ancestors.includes(tab)) { + log(' => ignore restored ancestor tab'); + return false; + } + + const allowed = await notifyToTryHandleNewTab(tab, { + context, + activeTab, + }); + if (!allowed) { + log(' => handling is canceled by someone'); + return false; + } + + const moved = await Tree.behaveAutoAttachedTab(tab, { + baseTab: activeTab, + behavior: autoAttachBehavior, + broadcast: true, + dontMove: dontMove || false + }); + if (openedWithCookieStoreId) { + log('handleNewTabFromActiveTab: do not reopen tab opened with contextual identity explicitly'); + return moved; + } + if (tab.cookieStoreId && tab.cookieStoreId != 'firefox-default') { + log('handleNewTabFromActiveTab: do not reopen tab opened with non-default contextual identity ', tab.cookieStoreId); + return moved; + } + + const parent = tab.$TST.parent; + let cookieStoreId = null; + switch (inheritContextualIdentityMode) { + case Constants.kCONTEXTUAL_IDENTITY_FROM_PARENT: + if (parent) + cookieStoreId = parent.cookieStoreId; + break; + + case Constants.kCONTEXTUAL_IDENTITY_FROM_LAST_ACTIVE: + cookieStoreId = activeTab.cookieStoreId + break; + + default: + return moved; + } + if ((tab.cookieStoreId || 'firefox-default') == (cookieStoreId || 'firefox-default')) { + log('handleNewTabFromActiveTab: no need to reopen with inherited contextual identity ', cookieStoreId); + return moved; + } + + if (!configs.inheritContextualIdentityToUnopenableURLTabs && + !TabsOpen.isOpenable(url)) { + log('handleNewTabFromActiveTab: not openable URL, skip reopening ', cookieStoreId, url); + return moved; + } + + log('handleNewTabFromActiveTab: reopen with inherited contextual identity ', cookieStoreId); + // We need to prevent grouping of this original tab and the reopened tab + // by the "multiple tab opened in XXX msec" feature. + const win = TabsStore.windows.get(tab.windowId); + win.openedNewTabs.delete(tab.id); + await TabsOpen.openURIInTab(url || null, { + windowId: activeTab.windowId, + parent, + insertBefore: tab, + active: tab.active, + cookieStoreId + }); + TabsInternalOperation.removeTab(tab); + return moved; +} + +async function handleTabsFromPinnedOpener(tab, opener, { activeTab } = {}) { + const allowed = await notifyToTryHandleNewTab(tab, { + context: TSTAPI.kNEWTAB_CONTEXT_FROM_PINNED, + activeTab, + openerTab: opener, + }); + if (!allowed) { + log('handleTabsFromPinnedOpener: handling is canceled by someone'); + return false; + } + + const parent = Tab.getGroupTabForOpener(opener); + if (parent) { + log('handleTabsFromPinnedOpener: attach to corresponding group tab'); + tab.$TST.setAttribute(Constants.kPERSISTENT_ALREADY_GROUPED_FOR_PINNED_OPENER, true); + tab.$TST.temporaryMetadata.set('alreadyMovedAsOpenedFromPinnedOpener', true); + // it could be updated already... + const lastRelatedTab = opener.$TST.lastRelatedTabId == tab.id ? + opener.$TST.previousLastRelatedTab : + opener.$TST.lastRelatedTab; + // If there is already opened group tab, it is more natural that + // opened tabs are treated as a tab opened from unpinned tabs. + const insertAt = configs.autoAttachOnOpenedWithOwner == Constants.kNEWTAB_OPEN_AS_CHILD_NEXT_TO_LAST_RELATED_TAB ? + Constants.kINSERT_NEXT_TO_LAST_RELATED_TAB : + configs.autoAttachOnOpenedWithOwner == Constants.kNEWTAB_OPEN_AS_CHILD_TOP ? + Constants.kINSERT_TOP : + configs.autoAttachOnOpenedWithOwner == Constants.kNEWTAB_OPEN_AS_CHILD_END ? + Constants.kINSERT_END : + undefined; + return Tree.attachTabTo(tab, parent, { + lastRelatedTab, + insertAt, + forceExpand: true, // this is required to avoid the group tab itself is active from active tab in collapsed tree + broadcast: true + }); + } + + if ((configs.autoGroupNewTabsFromPinned || + configs.autoGroupNewTabsFromFirefoxView) && + tab.$TST.needToBeGroupedSiblings.length > 0) { + log('handleTabsFromPinnedOpener: controlled by auto-grouping'); + return false; + } + + switch (isFirefoxViewTab(opener) ? configs.insertNewTabFromFirefoxViewAt : configs.insertNewTabFromPinnedTabAt) { + case Constants.kINSERT_NEXT_TO_LAST_RELATED_TAB: { + // it could be updated already... + const lastRelatedTab = opener.$TST.lastRelatedTab != tab ? + opener.$TST.lastRelatedTab : + opener.$TST.previousLastRelatedTab; + if (lastRelatedTab) { + log(`handleTabsFromPinnedOpener: place after last related tab ${dumpTab(lastRelatedTab)}`); + tab.$TST.temporaryMetadata.set('alreadyMovedAsOpenedFromPinnedOpener', true); + return TabsMove.moveTabAfter(tab, lastRelatedTab.$TST.lastDescendant || lastRelatedTab, { + delayedMove: true, + broadcast: true + }); + } + const lastPinnedTab = Tab.getLastPinnedTab(tab.windowId); + if (lastPinnedTab) { + log(`handleTabsFromPinnedOpener: place after last pinned tab ${dumpTab(lastPinnedTab)}`); + tab.$TST.temporaryMetadata.set('alreadyMovedAsOpenedFromPinnedOpener', true); + return TabsMove.moveTabAfter(tab, lastPinnedTab, { + delayedMove: true, + broadcast: true + }); + } + const firstNormalTab = Tab.getFirstNormalTab(tab.windowId); + if (firstNormalTab) { + log(`handleTabsFromPinnedOpener: place before first unpinned tab ${dumpTab(firstNormalTab)}`); + tab.$TST.temporaryMetadata.set('alreadyMovedAsOpenedFromPinnedOpener', true); + return TabsMove.moveTabBefore(tab, firstNormalTab, { + delayedMove: true, + broadcast: true + }); + } + }; + case Constants.kINSERT_TOP: { + const lastPinnedTab = Tab.getLastPinnedTab(tab.windowId); + if (lastPinnedTab) { + log(`handleTabsFromPinnedOpener: opened from pinned opener: place after last pinned tab ${dumpTab(lastPinnedTab)}`); + tab.$TST.temporaryMetadata.set('alreadyMovedAsOpenedFromPinnedOpener', true); + return TabsMove.moveTabAfter(tab, lastPinnedTab, { + delayedMove: true, + broadcast: true + }); + } + const firstNormalTab = Tab.getFirstNormalTab(tab.windowId); + if (firstNormalTab) { + log(`handleTabsFromPinnedOpener: opened from pinned opener: place before first pinned tab ${dumpTab(firstNormalTab)}`); + tab.$TST.temporaryMetadata.set('alreadyMovedAsOpenedFromPinnedOpener', true); + return TabsMove.moveTabBefore(tab, firstNormalTab, { + delayedMove: true, + broadcast: true + }); + } + }; break; + + case Constants.kINSERT_END: { + const lastTab = Tab.getLastTab(tab.windowId); + log('handleTabsFromPinnedOpener: opened from pinned opener: place after the last tab ', lastTab); + tab.$TST.temporaryMetadata.set('alreadyMovedAsOpenedFromPinnedOpener', true); + return TabsMove.moveTabAfter(tab, lastTab, { + delayedMove: true, + broadcast: true + }); + }; + } + + return Promise.resolve(false); +} + +Tab.onCreated.addListener((tab, info = {}) => { + if (!info.duplicated || + info.bypassTabControl) + return; + const original = info.originalTab; + log('duplicated ', dumpTab(tab), dumpTab(original)); + if (info.duplicatedInternally) { + log('duplicated by internal operation'); + tab.$TST.addState(Constants.kTAB_STATE_DUPLICATING, { broadcast: true }); + TabsStore.addDuplicatingTab(tab); + } + else { + // On old versions of Firefox, duplicated tabs had no openerTabId so they were + // not handled by Tab.onCreating listener. Today they are already handled before + // here, so this is just a failsafe (or for old versions of Firefox). + // See also: https://github.com/piroor/treestyletab/issues/2830#issuecomment-831414189 + Tree.behaveAutoAttachedTab(tab, { + baseTab: original, + behavior: configs.autoAttachOnDuplicated, + dontMove: info.positionedBySelf || info.movedBySelfWhileCreation || info.mayBeReplacedWithContainer, + broadcast: true + }); + } +}); + +Tab.onUpdated.addListener((tab, changeInfo) => { + if ('openerTabId' in changeInfo && + configs.syncParentTabAndOpenerTab && + !tab.$TST.updatingOpenerTabIds.includes(changeInfo.openerTabId) /* accept only changes from outside of TST */) { + Tab.waitUntilTrackedAll(tab.windowId).then(() => { + const parent = tab.$TST.openerTab; + if (!parent || + parent.windowId != tab.windowId || + parent == tab.$TST.parent) + return; + Tree.attachTabTo(tab, parent, { + insertAt: Constants.kINSERT_NEAREST, + forceExpand: tab.active, + broadcast: true + }); + }); + } + + if (tab.$TST.temporaryMetadata.has('openedCompletely') && + tab.windowId == tab.$windowIdOnCreated && // Don't treat tab as "opened from active tab" if it is moved across windows while loading + (changeInfo.url || changeInfo.status == 'complete') && + (tab.$TST.temporaryMetadata.has('isNewTab') || + tab.$TST.temporaryMetadata.has('fromExternal') || + tab.$TST.temporaryMetadata.has('anyOtherTrigger'))) { + log('loaded tab ', dumpTab(tab), { + isNewTab: tab.$TST.temporaryMetadata.has('isNewTab'), + fromExternal: tab.$TST.temporaryMetadata.has('fromExternal'), + anyOtherTrigger: tab.$TST.temporaryMetadata.has('anyOtherTrigger'), + }); + tab.$TST.temporaryMetadata.delete('isNewTab'); + const possibleOpenerTab = Tab.get(tab.$TST.temporaryMetadata.get('possibleOpenerTab')); + tab.$TST.temporaryMetadata.delete('possibleOpenerTab'); + log('possibleOpenerTab ', dumpTab(possibleOpenerTab)); + + if (tab.$TST.temporaryMetadata.has('fromExternal')) { + tab.$TST.temporaryMetadata.delete('fromExternal'); + log('behave as a tab opened from external application (delayed)'); + handleNewTabFromActiveTab(tab, { + url: tab.url, + activeTab: possibleOpenerTab, + autoAttachBehavior: configs.autoAttachOnOpenedFromExternal, + inheritContextualIdentityMode: configs.inheritContextualIdentityToTabsFromExternalMode, + context: TSTAPI.kNEWTAB_CONTEXT_FROM_EXTERNAL, + }); + return; + } + + if (tab.$TST.temporaryMetadata.has('anyOtherTrigger')) { + tab.$TST.temporaryMetadata.delete('anyOtherTrigger'); + log('behave as a tab opened from any other trigger (delayed)'); + handleNewTabFromActiveTab(tab, { + url: tab.url, + activeTab: possibleOpenerTab, + autoAttachBehavior: configs.autoAttachOnAnyOtherTrigger, + inheritContextualIdentityMode: configs.inheritContextualIdentityToTabsFromAnyOtherTriggerMode, + context: TSTAPI.kNEWTAB_CONTEXT_UNKNOWN, + }); + return; + } + + const win = TabsStore.windows.get(tab.windowId); + log('win.openedNewTabs ', win.openedNewTabs); + if (tab.$TST.parent || + !possibleOpenerTab || + win.openedNewTabs.has(tab.id) || + tab.$TST.temporaryMetadata.has('openedWithOthers') || + tab.$TST.temporaryMetadata.has('positionedBySelf')) { + log(' => no need to control ', { + parent: tab.$TST.parent, + possibleOpenerTab, + openedNewTab: win.openedNewTabs.has(tab.id), + openedWithOthers: tab.$TST.temporaryMetadata.has('openedWithOthers'), + positionedBySelf: tab.$TST.temporaryMetadata.has('positionedBySelf') + }); + return; + } + + if (tab.$TST.isNewTabCommandTab) { + log('behave as a tab opened by new tab command (delayed)'); + tab.$TST.addState(Constants.kTAB_STATE_NEW_TAB_COMMAND_TAB); + handleNewTabFromActiveTab(tab, { + activeTab: possibleOpenerTab, + autoAttachBehavior: configs.autoAttachOnNewTabCommand, + inheritContextualIdentityMode: configs.inheritContextualIdentityToChildTabMode, + context: TSTAPI.kNEWTAB_CONTEXT_NEWTAB_COMMAND, + }); + return; + } + + const siteMatcher = /^\w+:\/\/([^\/]+)(?:$|\/.*$)/; + const openerTabSite = possibleOpenerTab.url.match(siteMatcher); + const newTabSite = tab.url.match(siteMatcher); + if (openerTabSite && + newTabSite && + tab.url != possibleOpenerTab.url && // It may be opened by "Duplciate Tab" or "Open in New Container Tab" if the URL is completely same. + openerTabSite[1] == newTabSite[1]) { + log('behave as a tab opened from same site (delayed)'); + tab.$TST.addState(Constants.kTAB_STATE_OPENED_FOR_SAME_WEBSITE); + handleNewTabFromActiveTab(tab, { + url: tab.url, + activeTab: possibleOpenerTab, + autoAttachBehavior: configs.autoAttachSameSiteOrphan, + inheritContextualIdentityMode: configs.inheritContextualIdentityToSameSiteOrphanMode, + context: TSTAPI.kNEWTAB_CONTEXT_WEBSITE_SAME_TO_ACTIVE_TAB, + }); + return; + } + + log('checking special openers (delayed)', { opener: possibleOpenerTab.url, child: tab.url }); + for (const rule of Constants.kAGGRESSIVE_OPENER_TAB_DETECTION_RULES_WITH_URL) { + if (rule.opener.test(possibleOpenerTab.url) && + rule.child.test(tab.url)) { + log('behave as a tab opened from special opener (delayed)', { rule }); + handleNewTabFromActiveTab(tab, { + url: tab.url, + activeTab: possibleOpenerTab, + autoAttachBehavior: configs.autoAttachOnOpenedWithOwner, + context: TSTAPI.kNEWTAB_CONTEXT_FROM_ABOUT_ADDONS, + }); + return; + } + } + } +}); + + +Tab.onAttached.addListener(async (tab, attachInfo = {}) => { + if (!attachInfo.windowId) + return; + + const parentTabOperationBehavior = TreeBehavior.getParentTabOperationBehavior(tab, { + context: Constants.kPARENT_TAB_OPERATION_CONTEXT_MOVE, + ...attachInfo, + }); + if (parentTabOperationBehavior != Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE) + return; + + log('Tabs.onAttached ', dumpTab(tab), attachInfo); + + log('descendants of attached tab: ', () => attachInfo.descendants.map(dumpTab)); + const movedTabs = await Tree.moveTabs(attachInfo.descendants, { + destinationWindowId: tab.windowId, + insertAfter: tab + }); + log('moved descendants: ', () => movedTabs.map(dumpTab)); + if (attachInfo.descendants.length == movedTabs.length) { + await Tree.applyTreeStructureToTabs( + [tab, ...movedTabs], + attachInfo.structure + ); + } + else { + for (const movedTab of movedTabs) { + Tree.attachTabTo(movedTab, tab, { + broadcast: true, + dontMove: true + }); + } + } +}); diff --git a/waterfox/browser/components/sidebar/background/handle-removed-tabs.js b/waterfox/browser/components/sidebar/background/handle-removed-tabs.js new file mode 100644 index 000000000000..9cd6ad31d243 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/handle-removed-tabs.js @@ -0,0 +1,292 @@ +/* +# 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'; + +import { + log as internalLogger, + dumpTab, + wait, + mapAndFilterUniq, + countMatched, + configs +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TabsInternalOperation from '/common/tabs-internal-operation.js'; +import * as TreeBehavior from '/common/tree-behavior.js'; + +import { Tab } from '/common/TreeItem.js'; + +import * as Background from './background.js'; +import * as TabsGroup from './tabs-group.js'; +import * as Tree from './tree.js'; +import * as Commands from './commands.js'; + +function log(...args) { + internalLogger('background/handle-removed-tabs', ...args); +} + + +Tab.onRemoving.addListener(async (tab, removeInfo = {}) => { + log('Tabs.onRemoving ', dumpTab(tab), removeInfo); + if (removeInfo.isWindowClosing) + return; + + let closeParentBehavior; + let newParent; + const successor = tab.$TST.possibleSuccessorWithDifferentContainer; + if (successor) { + log('override closeParentBehaior with kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD for different container successor ', successor); + closeParentBehavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD; + // When a new tab is created with a different container and this tab + // is removed immediately before the new tab is completely handled, + // TST fails to detect the new tab as the successor of this tab. Thus, + // we treat the new tab as the successor - the first child of this + // (actually not attached to this tab yet). + if (successor && successor != tab.$TST.firstChild) + newParent = successor; + } + else { + closeParentBehavior = TreeBehavior.getParentTabOperationBehavior(tab, { + context: Constants.kPARENT_TAB_OPERATION_CONTEXT_CLOSE, + ...removeInfo, + }); + log('detected closeParentBehaior: ', closeParentBehavior); + } + + if (closeParentBehavior != Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE && + tab.$TST.subtreeCollapsed) + Tree.collapseExpandSubtree(tab, { + collapsed: false, + justNow: true, + broadcast: false // because the tab is going to be closed, broadcasted Tree.collapseExpandSubtree can be ignored. + }); + + const postProcessParams = { + windowId: tab.windowId, + removedTab: tab.$TST.export(true), + structure: TreeBehavior.getTreeStructureFromTabs([tab, ...tab.$TST.descendants], { + full: true, + keepParentOfRootTabs: true + }), + insertBefore: tab, // not firstChild, because the "tab" is disappeared from tree. + parent: tab.$TST.parent, + newParent, + children: tab.$TST.children, + descendants: tab.$TST.descendants, + nearestFollowingRootTab: tab.$TST.nearestFollowingRootTab, + closeParentBehavior + }; + + if (tab.$TST.subtreeCollapsed) { + tryGrantCloseTab(tab, closeParentBehavior).then(async granted => { + if (!granted) + return; + log('Tabs.onRemoving: granted to close ', dumpTab(tab)); + handleRemovingPostProcess(postProcessParams) + }); + // First we always need to detach children from the closing parent. + // They will be processed again after confirmation. + Tree.detachAllChildren(tab, { + newParent, + behavior: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN, + dontExpand: true, + dontUpdateIndent: true, + broadcast: true + }); + } + else { + await handleRemovingPostProcess(postProcessParams) + } + + const win = TabsStore.windows.get(tab.windowId); + if (!win.internalClosingTabs.has(tab.$TST.parentId)) + Tree.detachTab(tab, { + dontUpdateIndent: true, + dontSyncParentToOpenerTab: true, + broadcast: true + }); +}); +async function handleRemovingPostProcess({ closeParentBehavior, windowId, parent, newParent, insertBefore, nearestFollowingRootTab, children, descendants, removedTab, structure } = {}) { + log('handleRemovingPostProcess ', { closeParentBehavior, windowId, parent, newParent, insertBefore, nearestFollowingRootTab, children, descendants, removedTab, structure }); + if (closeParentBehavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE) + await closeChildTabs(descendants, { + triggerTab: removedTab, + originalStructure: structure + }); + + const replacedGroupTab = (closeParentBehavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB) ? + await TabsGroup.tryReplaceTabWithGroup(null, { windowId, parent, children, insertBefore, newParent }) : + null; + + if (!replacedGroupTab) { + await Tree.detachAllChildren(null, { + windowId, + parent, + newParent, + children, + descendants, + nearestFollowingRootTab, + behavior: closeParentBehavior, + broadcast: true + }); + } +} + +async function tryGrantCloseTab(tab, closeParentBehavior) { + log('tryGrantClose: ', { alreadyGranted: configs.grantedRemovingTabIds, closing: dumpTab(tab) }); + const alreadyGranted = configs.grantedRemovingTabIds.includes(tab.id); + configs.grantedRemovingTabIds = configs.grantedRemovingTabIds.filter(id => id != tab.id); + if (!tab || alreadyGranted) { + log(' => no need to confirm'); + return true; + } + + const self = tryGrantCloseTab; + + self.closingTabIds.push(tab.id); + if (closeParentBehavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE) { + self.closingDescendantTabIds = self.closingDescendantTabIds + .concat(TreeBehavior.getClosingTabsFromParent(tab).map(tab => tab.id)); + self.closingDescendantTabIds = Array.from(new Set(self.closingDescendantTabIds)); + } + + if (self.promisedGrantedToCloseTabs) { + log(' => have promisedGrantedToCloseTabs'); + return self.promisedGrantedToCloseTabs; + } + + self.closingTabWasActive = self.closingTabWasActive || tab.active; + + let shouldRestoreCount; + self.promisedGrantedToCloseTabs = wait(250).then(async () => { + log(' => confirmation with delay'); + const closingTabIds = new Set(self.closingTabIds); + let allClosingTabs = new Set(); + allClosingTabs.add(tab); + self.closingTabIds = Array.from(closingTabIds); + self.closingDescendantTabIds = mapAndFilterUniq(self.closingDescendantTabIds, id => { + if (closingTabIds.has(id)) + return undefined; + const tab = Tab.get(id); + if (tab) // ignore already closed tabs + allClosingTabs.add(tab); + return id; + }); + allClosingTabs = Array.from(allClosingTabs); + shouldRestoreCount = self.closingTabIds.length; + const restorableClosingTabsCount = countMatched( + allClosingTabs, + tab => tab.url != 'about:blank' && + !tab.$TST.isNewTabCommandTab + ); + log(' => restorableClosingTabsCount: ', restorableClosingTabsCount); + if (restorableClosingTabsCount > 0) { + log('tryGrantClose: show confirmation for ', allClosingTabs); + return Background.confirmToCloseTabs(allClosingTabs.slice(1).map(tab => tab.$TST.sanitized), { + windowId: tab.windowId, + messageKey: 'warnOnCloseTabs_fromOutside_message', + titleKey: 'warnOnCloseTabs_fromOutside_title', + minConfirmCount: 0 + }); + } + return true; + }) + .then(async (granted) => { + log(' => granted: ', granted); + // remove the closed tab itself because it is already closed! + configs.grantedRemovingTabIds = configs.grantedRemovingTabIds.filter(id => id != tab.id); + if (granted) + return true; + log(`tryGrantClose: not granted, restore ${shouldRestoreCount} tabs`); + // this is required to wait until the closing tab is stored to the "recently closed" list + wait(0).then(async () => { + const restoredTabs = await Commands.restoreTabs(shouldRestoreCount); + log('tryGrantClose: restored ', restoredTabs); + }); + return false; + }); + + const granted = await self.promisedGrantedToCloseTabs; + self.closingTabIds = []; + self.closingDescendantTabIds = []; + self.closingTabWasActive = false; + self.promisedGrantedToCloseTabs = null; + return granted; +} +tryGrantCloseTab.closingTabIds = []; +tryGrantCloseTab.closingDescendantTabIds = []; +tryGrantCloseTab.closingTabWasActive = false; +tryGrantCloseTab.promisedGrantedToCloseTabs = null; + +async function closeChildTabs(tabs, { triggerTab, originalStructure } = {}) { + //if (!fireTabSubtreeClosingEvent(parent, tabs)) + // return; + + //markAsClosedSet([parent].concat(tabs)); + // close bottom to top! + await TabsInternalOperation.removeTabs(tabs.slice(0).reverse(), { triggerTab, originalStructure }); + //fireTabSubtreeClosedEvent(parent, tabs); +} + +Tab.onRemoved.addListener((tab, info) => { + log('Tabs.onRemoved: removed ', dumpTab(tab)); + configs.grantedRemovingTabIds = configs.grantedRemovingTabIds.filter(id => id != tab.id); + + if (info.isWindowClosing) + return; + + // The removing tab may be attached to another tab or + // other tabs may be attached to the removing tab. + // We need to detach such relations always on this timing. + if (info.oldChildren.length > 0) { + Tree.detachAllChildren(tab, { + children: info.oldChildren, + parent: info.oldParent, + behavior: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + broadcast: true + }); + } + if (info.oldParent) { + Tree.detachTab(tab, { + dontSyncParentToOpenerTab: true, + parent: info.oldParent, + broadcast: true + }); + } +}); + +browser.windows.onRemoved.addListener(windowId => { + const win = TabsStore.windows.get(windowId); + if (!win) + return; + configs.grantedRemovingTabIds = configs.grantedRemovingTabIds.filter(id => !win.tabs.has(id)); +}); + + +Tab.onDetached.addListener((tab, info = {}) => { + log('Tabs.onDetached ', dumpTab(tab)); + let closeParentBehavior = TreeBehavior.getParentTabOperationBehavior(tab, { + context: Constants.kPARENT_TAB_OPERATION_CONTEXT_MOVE, + ...info + }); + if (closeParentBehavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE) + closeParentBehavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD; + + const dontSyncParentToOpenerTab = info.trigger == 'tabs.onDetached'; + Tree.detachAllChildren(tab, { + dontSyncParentToOpenerTab, + behavior: closeParentBehavior, + broadcast: true + }); + //reserveCloseRelatedTabs(toBeClosedTabs); + Tree.detachTab(tab, { + dontSyncParentToOpenerTab, + dontUpdateIndent: true, + broadcast: true + }); + //restoreTabAttributes(tab, backupAttributes); +}); diff --git a/waterfox/browser/components/sidebar/background/handle-tab-bunches.js b/waterfox/browser/components/sidebar/background/handle-tab-bunches.js new file mode 100644 index 000000000000..5565a985f2b2 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/handle-tab-bunches.js @@ -0,0 +1,549 @@ +/* +# 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'; + +import { + log as internalLogger, + dumpTab, + configs, + sanitizeForHTMLText, + compareAsNumber, + isFirefoxViewTab, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Bookmark from '/common/bookmark.js'; +import * as Constants from '/common/constants.js'; +import * as Dialog from '/common/dialog.js'; +import * as Permissions from '/common/permissions.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TSTAPI from '/common/tst-api.js'; + +import { Tab, TreeItem } from '/common/TreeItem.js'; + +import * as TabsGroup from './tabs-group.js'; +import * as TabsOpen from './tabs-open.js'; +import * as Tree from './tree.js'; + +function log(...args) { + internalLogger('background/handle-tab-bunches', ...args); +} + +// ==================================================================== +// Detection of a bunch of tabs opened at same time. +// Firefox's WebExtensions API doesn't provide ability to know which tabs +// are opened together by a single trigger. Thus TST tries to detect such +// "tab bunches" based on their opened timing. +// ==================================================================== + +Tab.onBeforeCreate.addListener(async (tab, info) => { + const win = TabsStore.windows.get(tab.windowId); + if (!win) + return; + + const openerId = tab.openerTabId; + const openerTab = openerId && (await browser.tabs.get(openerId).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError))); + if ((openerTab && + (openerTab.pinned || isFirefoxViewTab(openerTab)) && + openerTab.windowId == tab.windowId) || + (!openerTab && + !info.maybeOrphan)) { + if (win.preventToDetectTabBunchesUntil > Date.now()) { + win.preventToDetectTabBunchesUntil += configs.tabBunchesDetectionTimeout; + } + else { + win.openedNewTabs.set(tab.id, { + id: tab.id, + windowId: tab.windowId, + indexOnCreated: tab.$indexOnCreated, + openerId: openerTab?.id, + openerIsPinned: openerTab?.pinned, + openerIsFirefoxView: isFirefoxViewTab(openerTab), + maybeFromBookmark: tab.$TST.maybeFromBookmark, + shouldNotGrouped: TSTAPI.isGroupingBlocked(), + }); + } + } + if (win.delayedTabBunchesDetection) + clearTimeout(win.delayedTabBunchesDetection); + win.delayedTabBunchesDetection = setTimeout( + tryDetectTabBunches, + configs.tabBunchesDetectionTimeout, + win + ); +}); + +const mPossibleTabBunchesToBeGrouped = []; +const mPossibleTabBunchesFromBookmarks = []; + +async function tryDetectTabBunches(win) { + if (Tab.needToWaitTracked(win.id)) + await Tab.waitUntilTrackedAll(win.id); + if (Tab.needToWaitMoved(win.id)) + await Tab.waitUntilMovedAll(win.id); + + let tabReferences = Array.from(win.openedNewTabs.values()); + log('tryDetectTabBunches for ', tabReferences); + + win.openedNewTabs.clear(); + + tabReferences = tabReferences.filter(tabReference => { + if (!tabReference.id) + return false; + const tab = Tab.get(tabReference.id); + if (!tab) + return false; + const uniqueId = tab?.$TST?.uniqueId; + return !uniqueId || (!uniqueId.duplicated && !uniqueId.restored); + }); + if (tabReferences.length == 0) { + log(' => there is no possible bunches of tabs.'); + return; + } + + if (tabReferences.length > 1) { + await Promise.all(tabReferences.map(tabReference => { + const tab = Tab.get(tabReference.id); + tab.$TST.temporaryMetadata.set('openedWithOthers', true); + // We need to wait until all tabs are handlede completely. + // Otherwise `tab.$TST.needToBeGroupedSiblings` may contain unrelated tabs + // (tabs opened from any other parent tab) unexpectedly. + return tab.$TST.opened; + })); + } + + if (areTabsFromOtherDeviceWithInsertAfterCurrent(tabReferences) && + configs.fixupOrderOfTabsFromOtherDevice) { + const ids = tabReferences.map(tabReference => tabReference.id); + const index = tabReferences.map(tabReference => Tab.get(tabReference.id).index).sort(compareAsNumber)[0]; + log(' => gather tabs from other device at ', ids, index); + await browser.tabs.move(ids, { index }); + } + + if (configs.autoGroupNewTabsFromPinned || + configs.autoGroupNewTabsFromFirefoxView || + configs.autoGroupNewTabsFromOthers) { + mPossibleTabBunchesToBeGrouped.push(tabReferences); + tryGroupTabBunches(); + } + if (configs.autoGroupNewTabsFromBookmarks || + configs.restoreTreeForTabsFromBookmarks) { + mPossibleTabBunchesFromBookmarks.push(tabReferences); + tryHandlTabBunchesFromBookmarks(); + } +} + +async function tryGroupTabBunches() { + if (tryGroupTabBunches.running) + return; + + const tabReferences = mPossibleTabBunchesToBeGrouped.shift(); + if (!tabReferences) + return; + + log('tryGroupTabBunches for ', tabReferences); + tryGroupTabBunches.running = true; + try { + const fromPinned = []; + const fromOthers = []; + // extract only pure new tabs + for (const tabReference of tabReferences) { + if (tabReference.shouldNotGrouped) + continue; + const tab = Tab.get(tabReference.id); + if (!tab) + continue; + if (tabReference.openerTabId) + tab.openerTabId = parseInt(tabReference.openerTabId); // restore the opener information + const uniqueId = tab.$TST.uniqueId; + if (tab.$TST.isGroupTab || uniqueId.duplicated || uniqueId.restored) + continue; + if (tabReference.openerIsPinned || + tabReference.openerIsFirefoxView) { + // We should check the "autoGroupNewTabsFromPinned" config here, + // because to-be-grouped tabs should be ignored by the handler for + // "autoAttachSameSiteOrphan" behavior. + if ((tab.$TST.hasPinnedOpener && + configs.autoGroupNewTabsFromPinned) || + (tab.$TST.hasFirefoxViewOpener && + configs.autoGroupNewTabsFromFirefoxView)) + fromPinned.push(tab); + } + else { + fromOthers.push(tab); + } + } + log(' => ', { fromPinned, fromOthers }); + + if (fromPinned.length > 0 && + (configs.autoGroupNewTabsFromPinned || + configs.autoGroupNewTabsFromFirefoxView)) { + const newRootTabs = Tab.collectRootTabs(TreeItem.sort(fromPinned)); + if (newRootTabs.length > 0) { + await tryGroupTabBunchesFromPinnedOpener(newRootTabs); + } + } + + // We can assume that new tabs from a bookmark folder and from other + // sources won't be mixed. + const openedFromBookmarkFolder = fromOthers.length > 0 && await detectBookmarkFolderFromTabs(fromOthers, tabReferences.length); + log(' => tryGroupTabBunches:openedFromBookmarkFolder: ', !!openedFromBookmarkFolder); + const newRootTabs = Tab.collectRootTabs(TreeItem.sort(openedFromBookmarkFolder ? openedFromBookmarkFolder.tabs : fromOthers)); + log(' newRootTabs: ', newRootTabs); + if (newRootTabs.length > 1 && + !openedFromBookmarkFolder && // we should ignore tabs from bookmark folder: they should be handled by tryHandlTabBunchesFromBookmarks + configs.autoGroupNewTabsFromOthers) { + const granted = await confirmToAutoGroupNewTabsFromOthers(fromOthers); + if (granted) + await TabsGroup.groupTabs(newRootTabs, { + ...TabsGroup.temporaryStateParams(configs.groupTabTemporaryStateForNewTabsFromOthers), + broadcast: true + }); + } + } + catch(error) { + log('Error on tryGroupTabBunches: ', String(error), error.stack); + } + finally { + tryGroupTabBunches.running = false; + if (mPossibleTabBunchesToBeGrouped.length > 0) + tryGroupTabBunches(); + } +} + +async function confirmToAutoGroupNewTabsFromOthers(tabs) { + if (tabs.length <= 1 || + !configs.warnOnAutoGroupNewTabs) + return true; + + const windowId = tabs[0].windowId; + const win = await browser.windows.get(windowId); + + const listing = configs.warnOnAutoGroupNewTabsWithListing ? + Dialog.tabsToHTMLList(tabs, { + maxRows: configs.warnOnAutoGroupNewTabsWithListingMaxRows, + maxHeight: Math.round(win.height * 0.8), + maxWidth: Math.round(win.width * 0.75) + }) : + ''; + const result = await Dialog.show(win, { + content: ` +
${sanitizeForHTMLText(browser.i18n.getMessage('warnOnAutoGroupNewTabs_message', [tabs.length]))}
${listing} + `.trim(), + buttons: [ + browser.i18n.getMessage('warnOnAutoGroupNewTabs_close'), + browser.i18n.getMessage('warnOnAutoGroupNewTabs_cancel') + ], + checkMessage: browser.i18n.getMessage('warnOnAutoGroupNewTabs_warnAgain'), + checked: true, + modal: true, // for popup + type: 'common-dialog', // for popup + url: ((await Permissions.isGranted(Permissions.ALL_URLS)) ? null : '/resources/blank.html'), // for popup + title: browser.i18n.getMessage('warnOnAutoGroupNewTabs_title'), // for popup + onShownInPopup(container) { + setTimeout(() => { // because window.requestAnimationFrame is decelerate for an invisible document. + // this need to be done on the next tick, to use the height of the box for calculation of dialog size + const style = container.querySelector('ul').style; + style.height = '0px'; // this makes the box shrinkable + style.maxHeight = 'none'; + style.minHeight = '0px'; + }, 0); + } + }); + + switch (result.buttonIndex) { + case 0: + if (!result.checked) + configs.warnOnAutoGroupNewTabs = false; + return true; + case 1: + if (!result.checked) { + configs.warnOnAutoGroupNewTabs = false; + configs.autoGroupNewTabsFromOthers = false; + } + default: + return false; + } +} + +async function tryGroupTabBunchesFromPinnedOpener(rootTabs) { + log(`tryGroupTabBunchesFromPinnedOpener: ${rootTabs.length} root tabs are opened from pinned tabs`); + + // First, collect pinned opener tabs. + let pinnedOpeners = []; + const childrenOfPinnedTabs = {}; + for (const tab of rootTabs) { + const opener = tab.$TST.openerTab; + if (!pinnedOpeners.includes(opener)) + pinnedOpeners.push(opener); + } + log('pinnedOpeners ', () => pinnedOpeners.map(dumpTab)); + + // Second, collect tabs opened from pinned openers including existing tabs + // (which were left ungrouped in previous process). + const openerOf = {}; + const allRootTabs = await Tab.getRootTabs(rootTabs[0].windowId) + for (const tab of allRootTabs) { + if (tab.$TST.getAttribute(Constants.kPERSISTENT_ALREADY_GROUPED_FOR_PINNED_OPENER)) + continue; + if (rootTabs.includes(tab)) { // newly opened tab + const opener = tab.$TST.openerTab; + if (!opener) + continue; + openerOf[tab.id] = opener; + const tabs = childrenOfPinnedTabs[opener.id] || []; + childrenOfPinnedTabs[opener.id] = tabs.concat([tab]); + continue; + } + const opener = Tab.getByUniqueId(tab.$TST.getAttribute(Constants.kPERSISTENT_ORIGINAL_OPENER_TAB_ID)); + if (!opener || + !(opener.pinned || isFirefoxViewTab(opener)) || + opener.windowId != tab.windowId) + continue; + // existing and not yet grouped tab + if (!pinnedOpeners.includes(opener)) + pinnedOpeners.push(opener); + openerOf[tab.id] = opener; + const tabs = childrenOfPinnedTabs[opener.id] || []; + childrenOfPinnedTabs[opener.id] = tabs.concat([tab]); + } + + // Ignore pinned openeres which has no child tab to be grouped. + pinnedOpeners = pinnedOpeners.filter(opener => { + return childrenOfPinnedTabs[opener.id].length > 1 || Tab.getGroupTabForOpener(opener); + }); + log(' => ', () => pinnedOpeners.map(dumpTab)); + + // Move newly opened tabs to expected position before grouping! + // Note that we should refer "insertNewChildAt" instead of "insertNewTabFromPinnedTabAt" / "insertNewTabFromFirefoxViewAt" + // because these children are going to be controlled in a sub tree. + for (const tab of rootTabs.slice(0).sort((a, b) => a.id - b.id)/* process them in the order they were opened */) { + const opener = openerOf[tab.id]; + const siblings = tab.$TST.needToBeGroupedSiblings; + if (!pinnedOpeners.includes(opener) || + Tab.getGroupTabForOpener(opener) || + siblings.length == 0 || + tab.$TST.temporaryMetadata.has('alreadyMovedAsOpenedFromPinnedOpener')) + continue; + let refTabs = {}; + try { + refTabs = Tree.getReferenceTabsForNewChild(tab, null, { + lastRelatedTab: opener.$TST.previousLastRelatedTab, + parent: siblings[0], + children: siblings, + descendants: siblings.map(sibling => [sibling, ...sibling.$TST.descendants]).flat() + }); + } + catch(_error) { + // insertChildAt == "no control" case + } + if (refTabs.insertAfter) { + await Tree.moveTabSubtreeAfter( + tab, + refTabs.insertAfter, + { broadcast: true } + ); + log(`newly opened child ${tab.id} has been moved after ${refTabs.insertAfter?.id}`); + } + else if (refTabs.insertBefore) { + await Tree.moveTabSubtreeBefore( + tab, + refTabs.insertBefore, + { broadcast: true } + ); + log(`newly opened child ${tab.id} has been moved before ${refTabs.insertBefore?.id}`); + } + else { + continue; + } + tab.$TST.temporaryMetadata.set('alreadyMovedAsOpenedFromPinnedOpener', true); + } + + // Finally, try to group opened tabs. + const newGroupTabs = new Map(); + for (const opener of pinnedOpeners) { + const children = childrenOfPinnedTabs[opener.id].sort((a, b) => a.index - b.index); + let parent = Tab.getGroupTabForOpener(opener); + if (parent) { + for (const child of children) { + TabsStore.removeToBeGroupedTab(child); + } + continue; + } + log(`trying to group children of ${dumpTab(opener)}: `, () => children.map(dumpTab)); + const uri = TabsGroup.makeGroupTabURI({ + title: browser.i18n.getMessage('groupTab_fromPinnedTab_label', opener.title), + openerTabId: opener.$TST.uniqueId.id, + ...TabsGroup.temporaryStateParams(isFirefoxViewTab(opener) ? configs.groupTabTemporaryStateForChildrenOfFirefoxView : configs.groupTabTemporaryStateForChildrenOfPinned) + }); + parent = await TabsOpen.openURIInTab(uri, { + windowId: opener.windowId, + insertBefore: children[0], + cookieStoreId: opener.cookieStoreId, + inBackground: true + }); + log('opened group tab: ', dumpTab(parent)); + newGroupTabs.set(opener, true); + for (const child of children) { + // Prevent the tab to be grouped again after it is ungrouped manually. + child.$TST.setAttribute(Constants.kPERSISTENT_ALREADY_GROUPED_FOR_PINNED_OPENER, true); + TabsStore.removeToBeGroupedTab(child); + await Tree.attachTabTo(child, parent, { + forceExpand: true, // this is required to avoid the group tab itself is active from active tab in collapsed tree + dontMove: true, + broadcast: true + }); + } + if (opener.active) + parent.$TST.addState(Constants.kTAB_STATE_BUNDLED_ACTIVE); + } + return true; +} + +async function tryHandlTabBunchesFromBookmarks() { + if (tryHandlTabBunchesFromBookmarks.running) + return; + + const tabReferences = mPossibleTabBunchesFromBookmarks.shift(); + if (!tabReferences) + return; + + log('tryHandlTabBunchesFromBookmarks for ', tabReferences); + tryHandlTabBunchesFromBookmarks.running = true; + try { + const tabs = []; + for (const tabReference of tabReferences) { + const tab = Tab.get(tabReference.id); + if (!tab) + continue; + if (tabReference.openerTabId) + tab.openerTabId = parseInt(tabReference.openerTabId); // restore the opener information + const uniqueId = tab.$TST.uniqueId; + if (uniqueId.duplicated || uniqueId.restored) + continue; + tabs.push(tab); + } + log(' => ', { tabs }); + + // We can assume that new tabs from a bookmark folder and from other + // sources won't be mixed. + const openedFromBookmarkFolder = tabs.length > 0 && await detectBookmarkFolderFromTabs(tabs, tabReferences.length); + log(' => tryHandlTabBunchesFromBookmarks:openedFromBookmarkFolder: ', openedFromBookmarkFolder); + if (openedFromBookmarkFolder) { + if (configs.restoreTreeForTabsFromBookmarks) { + log(' ==> trying to restore tree structure from bookmark information'); + const structure = await Bookmark.getTreeStructureFromBookmarkFolder(openedFromBookmarkFolder.folder); + log(' ==> structure:', structure); + if (structure.length == openedFromBookmarkFolder.tabs.length) { + log(' ===> apply'); + await Tree.applyTreeStructureToTabs(openedFromBookmarkFolder.tabs, structure); + } + } + + const newRootTabs = Tab.collectRootTabs(TreeItem.sort(openedFromBookmarkFolder.tabs)); + if (newRootTabs.length > 1 && + configs.autoGroupNewTabsFromBookmarks && + tabReferences.every(tabReference => !tabReference.shouldNotGrouped)) { + log(' => tryHandlTabBunchesFromBookmarks:group'); + await TabsGroup.groupTabs(openedFromBookmarkFolder.tabs, { + ...TabsGroup.temporaryStateParams(configs.groupTabTemporaryStateForNewTabsFromBookmarks), + broadcast: true + }); + } + } + } + catch(error) { + log('Error on tryHandlTabBunchesFromBookmarks: ', String(error), error.stack); + } + finally { + tryHandlTabBunchesFromBookmarks.running = false; + if (mPossibleTabBunchesFromBookmarks.length > 0) + tryHandlTabBunchesFromBookmarks(); + } +} + +async function detectBookmarkFolderFromTabs(tabs, allNewTabsCount = tabs.length) { + log('detectBookmarkFolderFromTabs: ', tabs, allNewTabsCount); + return new Promise((resolve, _reject) => { + const maybeFromBookmarks = []; + let restCount = tabs.length; + for (const tab of tabs) { + tab.$TST.promisedPossibleOpenerBookmarks.then(async bookmarks => { + log(` bookmarks from tab ${tab.id}: `, bookmarks); + restCount--; + if (bookmarks.length > 0) + maybeFromBookmarks.push(tab); + const folder = await tryDetectMostTabsContainedBookmarkFolder(maybeFromBookmarks, allNewTabsCount); + if (folder) { + log('detectBookmarkFolderFromTabs: found folder => ', { folder, tabs }); + resolve({ + folder, + tabs: maybeFromBookmarks, + }); + } + else if (restCount == 0) { + resolve(null); + } + }); + } + }); +} + +async function tryDetectMostTabsContainedBookmarkFolder(bookmarkedTabs, allNewTabsCount = bookmarkedTabs.length) { + log('tryDetectMostTabsContainedBookmarkFolder ', { bookmarkedTabs, allNewTabsCount }); + const parentIds = bookmarkedTabs.map(tab => tab.$TST.possibleOpenerBookmarks.map(bookmark => bookmark.parentId)).flat(); + const counts = []; + const countById = {}; + for (const id of parentIds) { + if (!(id in countById)) + counts.push(countById[id] = { id, count: 0 }); + countById[id].count++; + } + log(' counts: ', counts); + if (counts.length == 0) + return null; + + const greatestCountParent = counts.sort((a, b) => b.count - a.count)[0]; + const minCount = allNewTabsCount * configs.tabsFromSameFolderMinThresholdPercentage / 100; + log(' => ', { greatestCountParent, minCount }); + if (greatestCountParent.count <= minCount) + return null; + + const items = await browser.bookmarks.get(greatestCountParent.id); + return Array.isArray(items) ? items[0] : items; +} + + +// Detect tabs sent from other device with `browser.tabs.insertAfterCurrent`=true based on their index +// (Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1596787 ) +// See also: https://github.com/piroor/treestyletab/issues/2419 +function areTabsFromOtherDeviceWithInsertAfterCurrent(tabReferences) { + if (tabReferences.length == 0) + return false; + + const activeTab = Tab.getActiveTab(tabReferences[0].windowId); + if (!activeTab) + return false; + + const activeIndex = activeTab.index; + const followingTabsCount = Tab.getTabs(activeTab.windowId).filter(tab => tab.index > activeIndex).length; + + const createdCount = tabReferences.length; + const expectedIndices = [activeIndex + 1]; + const actualIndices = tabReferences.map(tabReference => tabReference.indexOnCreated); + + const overTabsCount = Math.max(0, createdCount - followingTabsCount); + const shouldCountDown = Math.min(createdCount - 1, createdCount - Math.floor(overTabsCount / 2)); + const shouldCountUp = createdCount - shouldCountDown - 1; + for (let i = 0; i < shouldCountUp; i++) { + expectedIndices.push(activeIndex + 2 + i + (createdCount - overTabsCount)); + } + for (let i = shouldCountDown - 1; i > -1; i--) { + expectedIndices.push(activeIndex + 1 + i); + } + const received = actualIndices.join(',') == expectedIndices.join(','); + log('areTabsFromOtherDeviceWithInsertAfterCurrent:', received, { overTabsCount, shouldCountUp, shouldCountDown, size: expectedIndices.length, actualIndices, expectedIndices }); + return received; +} diff --git a/waterfox/browser/components/sidebar/background/handle-tab-focus.js b/waterfox/browser/components/sidebar/background/handle-tab-focus.js new file mode 100644 index 000000000000..0351e52c7efa --- /dev/null +++ b/waterfox/browser/components/sidebar/background/handle-tab-focus.js @@ -0,0 +1,471 @@ +/* +# 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'; + +import { + log as internalLogger, + dumpTab, + wait, + configs, + isMacOS, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from '/common/constants.js'; +import * as Permissions from '/common/permissions.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TabsInternalOperation from '/common/tabs-internal-operation.js'; +import * as TSTAPI from '/common/tst-api.js'; + +import { Tab } from '/common/TreeItem.js'; +import Window from '/common/Window.js'; + +import * as Background from './background.js'; +import * as Tree from './tree.js'; + +function log(...args) { + internalLogger('background/handle-tab-focus', ...args); +} + + +const PHASE_LOADING = 0; +const PHASE_BACKGROUND_INITIALIZED = 1; +const PHASE_BACKGROUND_BUILT = 2; +const PHASE_BACKGROUND_READY = 3; +let mInitializationPhase = PHASE_LOADING; + +Background.onInit.addListener(() => { + mInitializationPhase = PHASE_BACKGROUND_INITIALIZED; +}); +Background.onBuilt.addListener(() => { + mInitializationPhase = PHASE_BACKGROUND_BUILT; +}); +Background.onReady.addListener(() => { + mInitializationPhase = PHASE_BACKGROUND_READY; +}); + + +let mTabSwitchedByShortcut = false; +let mMaybeTabSwitchingByShortcut = false; + +const mLastTabsCountInWindow = new Map(); + +Window.onInitialized.addListener(win => { + browser.tabs.query({ + windowId: win.id, + active: true + }) + .then(activeTabs => { + // There may be no active tab on a startup... + if (activeTabs.length > 0 && + !win.lastActiveTab) + win.lastActiveTab = activeTabs[0].id; + }); +}); + +browser.windows.onRemoved.addListener(windowId => { + mLastTabsCountInWindow.delete(windowId); +}); + + +Tab.onActivating.addListener(async (tab, info = {}) => { // return false if the activation should be canceled + log('Tabs.onActivating ', { tab: dumpTab(tab), info, mMaybeTabSwitchingByShortcut }); + + if (mMaybeTabSwitchingByShortcut) { + const lastCount = mLastTabsCountInWindow.get(tab.windowId); + const count = Tab.getAllTabs(tab.windowId).length; + if (lastCount != count) { + log('tabs are created or removed: cancel tab switching'); + mMaybeTabSwitchingByShortcut = false; + } + mLastTabsCountInWindow.set(tab.windowId, count); + } + + if (tab.$TST.temporaryMetadata.has('shouldReloadOnSelect')) { + browser.tabs.reload(tab.id) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + tab.$TST.temporaryMetadata.delete('shouldReloadOnSelect'); + } + const win = TabsStore.windows.get(tab.windowId); + log(' lastActiveTab: ', win.lastActiveTab); // it may be blank on a startup + const lastActiveTab = Tab.get(win.lastActiveTab || info.previousTabId); + cancelDelayedExpand(lastActiveTab); + const shouldSkipCollapsed = ( + !info.byInternalOperation && + mMaybeTabSwitchingByShortcut && + configs.skipCollapsedTabsForTabSwitchingShortcuts + ); + mTabSwitchedByShortcut = mMaybeTabSwitchingByShortcut; + const focusDirection = !lastActiveTab ? + 0 : + (!lastActiveTab.$TST.nearestVisiblePrecedingTab && + !tab.$TST.nearestVisibleFollowingTab) ? + -1 : + (!lastActiveTab.$TST.nearestVisibleFollowingTab && + !tab.$TST.nearestVisiblePrecedingTab) ? + 1 : + (lastActiveTab.index > tab.index) ? + -1 : + 1; + const cache = {}; + if (tab.$TST.collapsed) { + if (!tab.$TST.parent) { + // This is invalid case, generally never should happen, + // but actually happen on some environment: + // https://github.com/piroor/treestyletab/issues/1717 + // So, always expand orphan collapsed tab as a failsafe. + Tree.collapseExpandTab(tab, { + collapsed: false, + broadcast: true + }); + await handleNewActiveTab(tab, info); + } + else if (!shouldSkipCollapsed) { + log('=> reaction for focus given from outside of TST'); + let allowed = false; + if (configs.unfocusableCollapsedTab) { + log(' => apply unfocusableCollapsedTab'); + allowed = await TSTAPI.tryOperationAllowed( + TSTAPI.kNOTIFY_TRY_EXPAND_TREE_FROM_FOCUSED_COLLAPSED_TAB, + { tab, + focusDirection }, + { tabProperties: ['tab'], cache } + ); + TSTAPI.clearCache(cache); + if (allowed) { + const toBeExpandedAncestors = [tab].concat(tab.$TST.ancestors) ; + for (const ancestor of toBeExpandedAncestors) { + Tree.collapseExpandSubtree(ancestor, { + collapsed: false, + broadcast: true + }); + } + } + else { + log(' => canceled by someone.'); + } + } + info.allowed = allowed; + await handleNewActiveTab(tab, info); + } + if (shouldSkipCollapsed) { + log('=> reaction for focusing collapsed descendant while Ctrl-Tab/Ctrl-Shift-Tab'); + let successor = tab.$TST.nearestVisibleAncestorOrSelf; + if (!successor) // this seems invalid case... + return false; + log('successor = ', successor.id); + if (shouldSkipCollapsed && + (win.lastActiveTab == successor.id || + successor.$TST.descendants.some(tab => tab.id == win.lastActiveTab)) && + focusDirection > 0) { + log('=> redirect successor (focus moved from the successor itself or its descendants)'); + successor = successor.$TST.nearestVisibleFollowingTab; + if (successor && + successor.discarded && + configs.avoidDiscardedTabToBeActivatedIfPossible) + successor = successor.$TST.nearestLoadedTabInTree || + successor.$TST.nearestLoadedTab || + successor; + if (!successor) + successor = Tab.getFirstVisibleTab(tab.windowId); + log('=> ', successor.id); + } + else if (!mTabSwitchedByShortcut && // intentional focus to a discarded tabs by Ctrl-Tab/Ctrl-Shift-Tab is always allowed! + successor.discarded && + configs.avoidDiscardedTabToBeActivatedIfPossible) { + log('=> redirect successor (successor is discarded)'); + successor = successor.$TST.nearestLoadedTabInTree || + successor.$TST.nearestLoadedTab || + successor; + log('=> ', successor.id); + } + const allowed = await TSTAPI.tryOperationAllowed( + TSTAPI.kNOTIFY_TRY_REDIRECT_FOCUS_FROM_COLLAPSED_TAB, + { tab, + focusDirection }, + { tabProperties: ['tab'], cache } + ); + TSTAPI.clearCache(cache); + if (allowed) { + win.lastActiveTab = successor.id; + if (mMaybeTabSwitchingByShortcut) + setupDelayedExpand(successor); + TabsInternalOperation.activateTab(successor, { silently: true }); + log('Tabs.onActivating: discarded? ', dumpTab(tab), tab?.discarded); + if (tab.discarded) + tab.$TST.temporaryMetadata.set('discardURLAfterCompletelyLoaded', tab.url); + return false; + } + else { + log(' => canceled by someone.'); + } + } + } + else if (info.byActiveTabRemove && + (!configs.autoCollapseExpandSubtreeOnSelect || + configs.autoCollapseExpandSubtreeOnSelectExceptActiveTabRemove)) { + log('=> reaction for removing current tab'); + win.lastActiveTab = tab.id; + tryHighlightBundledTab(tab, { + ...info, + shouldSkipCollapsed + }); + return true; + } + else if (tab.$TST.hasChild && + tab.$TST.subtreeCollapsed && + !shouldSkipCollapsed) { + log('=> reaction for newly active parent tab'); + await handleNewActiveTab(tab, info); + } + tab.$TST.temporaryMetadata.delete('discardOnCompletelyLoaded'); + win.lastActiveTab = tab.id; + + if (mMaybeTabSwitchingByShortcut) + setupDelayedExpand(tab); + else + tryHighlightBundledTab(tab, { + ...info, + shouldSkipCollapsed + }); + + return true; +}); + +async function handleNewActiveTab(tab, { allowed, silently } = {}) { + log('handleNewActiveTab: ', dumpTab(tab), { allowed, silently }); + const shouldCollapseExpandNow = configs.autoCollapseExpandSubtreeOnSelect; + const canCollapseTree = shouldCollapseExpandNow; + const canExpandTree = shouldCollapseExpandNow && !silently; + if (canExpandTree && + allowed !== false) { + const cache = {}; + const allowed = await TSTAPI.tryOperationAllowed( + tab.active ? + TSTAPI.kNOTIFY_TRY_EXPAND_TREE_FROM_FOCUSED_PARENT : + TSTAPI.kNOTIFY_TRY_EXPAND_TREE_FROM_FOCUSED_BUNDLED_PARENT, + { tab }, + { tabProperties: ['tab'], cache } + ); + if (!allowed) + return; + if (canCollapseTree && + configs.autoExpandIntelligently) + await Tree.collapseExpandTreesIntelligentlyFor(tab, { + broadcast: true + }); + else + Tree.collapseExpandSubtree(tab, { + collapsed: false, + broadcast: true + }); + } +} + +async function tryHighlightBundledTab(tab, { shouldSkipCollapsed, allowed, silently } = {}) { + const bundledTab = tab.$TST.bundledTab; + const oldBundledTabs = TabsStore.bundledActiveTabsInWindow.get(tab.windowId); + log('tryHighlightBundledTab ', { + tab: tab.id, + bundledTab: bundledTab?.id, + oldBundledTabs, + shouldSkipCollapsed, + allowed, + silently, + }); + for (const tab of oldBundledTabs.values()) { + if (tab == bundledTab) + continue; + tab.$TST.removeState(Constants.kTAB_STATE_BUNDLED_ACTIVE); + } + + if (!bundledTab) + return; + + bundledTab.$TST.addState(Constants.kTAB_STATE_BUNDLED_ACTIVE); + + await wait(100); + if (!tab.active || // ignore tab already inactivated while waiting + tab.$TST.hasOtherHighlighted || // ignore manual highlighting + bundledTab.pinned || + !configs.syncActiveStateToBundledTabs) + return; + + if (bundledTab.$TST.hasChild && + bundledTab.$TST.subtreeCollapsed && + !shouldSkipCollapsed) + await handleNewActiveTab(bundledTab, { allowed, silently }); +} + +Tab.onUpdated.addListener((tab, changeInfo = {}) => { + if ('url' in changeInfo) { + if (tab.$TST.temporaryMetadata.has('discardURLAfterCompletelyLoaded') && + tab.$TST.temporaryMetadata.get('discardURLAfterCompletelyLoaded') != changeInfo.url) + tab.$TST.temporaryMetadata.delete('discardURLAfterCompletelyLoaded'); + } +}); + +Tab.onStateChanged.addListener(tab => { + if (!tab || + tab.status != 'complete') + return; + + if (typeof browser.tabs.discard == 'function') { + if (tab.url == tab.$TST.temporaryMetadata.get('discardURLAfterCompletelyLoaded') && + configs.autoDiscardTabForUnexpectedFocus) { + log('Try to discard accidentally restored tab (on restored) ', dumpTab(tab)); + wait(configs.autoDiscardTabForUnexpectedFocusDelay).then(() => { + if (!TabsStore.ensureLivingItem(tab) || + tab.active) + return; + if (tab.status == 'complete') + browser.tabs.discard(tab.id) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + else + tab.$TST.temporaryMetadata.set('discardOnCompletelyLoaded', true); + }); + } + else if (tab.$TST.temporaryMetadata.has('discardOnCompletelyLoaded') && !tab.active) { + log('Discard accidentally restored tab (on complete) ', dumpTab(tab)); + browser.tabs.discard(tab.id) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } + } + tab.$TST.temporaryMetadata.delete('discardURLAfterCompletelyLoaded'); + tab.$TST.temporaryMetadata.delete('discardOnCompletelyLoaded'); +}); + +async function setupDelayedExpand(tab) { + if (!tab) + return; + cancelDelayedExpand(tab); + TabsStore.removeToBeExpandedTab(tab); + const cache = {}; + const [ctrlTabHandlingEnabled, allowedToExpandViaAPI] = await Promise.all([ + Permissions.isGranted(Permissions.ALL_URLS), + TSTAPI.tryOperationAllowed( + TSTAPI.kNOTIFY_TRY_EXPAND_TREE_FROM_LONG_PRESS_CTRL_KEY, + { tab }, + { tabProperties: ['tab'], cache } + ), + ]); + if (!configs.autoExpandOnTabSwitchingShortcuts || + !tab.$TST.hasChild || + !tab.$TST.subtreeCollapsed || + !ctrlTabHandlingEnabled || + !allowedToExpandViaAPI) + return; + TabsStore.addToBeExpandedTab(tab); + tab.$TST.temporaryMetadata.set('delayedExpand', setTimeout(async () => { + if (!tab.$TST.temporaryMetadata.has('delayedExpand')) { // already canceled + log('delayed expand is already canceled ', tab.id); + return; + } + log('delayed expand by long-press of ctrl key on ', tab.id); + TabsStore.removeToBeExpandedTab(tab); + await Tree.collapseExpandTreesIntelligentlyFor(tab, { + broadcast: true + }); + }, configs.autoExpandOnTabSwitchingShortcutsDelay)); +} + +function cancelDelayedExpand(tab) { + if (!tab || + !tab.$TST.temporaryMetadata.has('delayedExpand')) + return; + clearTimeout(tab.$TST.temporaryMetadata.get('delayedExpand')); + tab.$TST.temporaryMetadata.delete('delayedExpand'); + TabsStore.removeToBeExpandedTab(tab); +} + +function cancelAllDelayedExpand(windowId) { + for (const tab of TabsStore.toBeExpandedTabsInWindow.get(windowId)) { + cancelDelayedExpand(tab); + } +} + +Tab.onCollapsedStateChanged.addListener((tab, info = {}) => { + tab.$TST.toggleState(Constants.kTAB_STATE_COLLAPSED_DONE, info.collapsed, { broadcast: false }); +}); + + +Background.onReady.addListener(() => { + for (const tab of Tab.getAllTabs(null, { iterator: true })) { + tab.$TST.removeState(Constants.kTAB_STATE_BUNDLED_ACTIVE); + } + for (const tab of Tab.getActiveTabs({ iterator: true })) { + tryHighlightBundledTab(tab); + } +}); + +browser.windows.onFocusChanged.addListener(() => { + mMaybeTabSwitchingByShortcut = false; +}); + +browser.runtime.onMessage.addListener(onMessage); + +function onMessage(message, sender) { + if (mInitializationPhase < PHASE_BACKGROUND_BUILT || + !message || + typeof message.type != 'string') + return; + + //log('onMessage: ', message, sender); + switch (message.type) { + case Constants.kNOTIFY_TAB_MOUSEDOWN: + mMaybeTabSwitchingByShortcut = + mTabSwitchedByShortcut = false; + break; + + case Constants.kCOMMAND_NOTIFY_MAY_START_TAB_SWITCH: { + if (message.modifier != (configs.accelKey || (isMacOS() ? 'meta' : 'control'))) + return; + log('kCOMMAND_NOTIFY_MAY_START_TAB_SWITCH ', message.modifier); + mMaybeTabSwitchingByShortcut = true; + if (sender.tab?.active) { + const win = TabsStore.windows.get(sender.tab.windowId); + win.lastActiveTab = sender.tab.id; + } + if (sender.tab) + mLastTabsCountInWindow.set(sender.tab.windowId, Tab.getAllTabs(sender.tab.windowId).length); + }; break; + case Constants.kCOMMAND_NOTIFY_MAY_END_TAB_SWITCH: + if (message.modifier != (configs.accelKey || (isMacOS() ? 'meta' : 'control'))) + return; + log('kCOMMAND_NOTIFY_MAY_END_TAB_SWITCH ', message.modifier); + return (async () => { + if (mTabSwitchedByShortcut && + configs.skipCollapsedTabsForTabSwitchingShortcuts && + sender.tab) { + await Tab.waitUntilTracked(sender.tab.id); + let tab = Tab.get(sender.tab.id); + if (!tab) { + let tabs = await browser.tabs.query({ currentWindow: true, active: true }).catch(ApiTabs.createErrorHandler()); + if (tabs.length == 0) + tabs = await browser.tabs.query({ currentWindow: true }).catch(ApiTabs.createErrorHandler()); + await Tab.waitUntilTracked(tabs[0].id); + tab = Tab.get(tabs[0].id); + } + cancelAllDelayedExpand(tab.windowId); + const cache = {}; + if (configs.autoCollapseExpandSubtreeOnSelect && + tab && + TabsStore.windows.get(tab.windowId).lastActiveTab == tab.id && + (await TSTAPI.tryOperationAllowed( + TSTAPI.kNOTIFY_TRY_EXPAND_TREE_FROM_END_TAB_SWITCH, + { tab }, + { tabProperties: ['tab'], cache } + ))) { + Tree.collapseExpandSubtree(tab, { + collapsed: false, + broadcast: true + }); + } + } + mMaybeTabSwitchingByShortcut = + mTabSwitchedByShortcut = false; + })(); + } +} diff --git a/waterfox/browser/components/sidebar/background/handle-tab-multiselect.js b/waterfox/browser/components/sidebar/background/handle-tab-multiselect.js new file mode 100644 index 000000000000..3ebb7921e77d --- /dev/null +++ b/waterfox/browser/components/sidebar/background/handle-tab-multiselect.js @@ -0,0 +1,38 @@ +/* +# 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'; + +import { + log as internalLogger, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; + +import { Tab } from '/common/TreeItem.js'; + +function log(...args) { + internalLogger('background/handle-tab-multiselect', ...args); +} + +Tab.onUpdated.addListener((tab, info, options = {}) => { + if (!('highlighted' in info) || + !tab.$TST.subtreeCollapsed || + tab.$TST.collapsed || + !tab.$TST.multiselected || + !options.inheritHighlighted) + return; + + const collapsedDescendants = tab.$TST.descendants; + log('inherit highlighted state from root visible tab: ', { + highlighted: info.highlighted, + collapsedDescendants + }); + for (const descendant of collapsedDescendants) { + browser.tabs.update(descendant.id, { + highlighted: info.highlighted, + active: descendant.active + }).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } +}); diff --git a/waterfox/browser/components/sidebar/background/handle-tree-changes.js b/waterfox/browser/components/sidebar/background/handle-tree-changes.js new file mode 100644 index 000000000000..c4266c14f23b --- /dev/null +++ b/waterfox/browser/components/sidebar/background/handle-tree-changes.js @@ -0,0 +1,140 @@ +/* +# 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'; + +import { + log as internalLogger, + configs +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TreeBehavior from '/common/tree-behavior.js'; + +import { Tab, TreeItem } from '/common/TreeItem.js'; + +import * as Background from './background.js'; +import * as BackgroundCache from './background-cache.js'; +import * as Tree from './tree.js'; +import * as TreeStructure from './tree-structure.js'; + +function log(...args) { + internalLogger('background/handle-tree-changes', ...args); +} + +let mInitialized = false; + +function reserveDetachHiddenTab(tab) { + reserveDetachHiddenTab.tabs.add(tab); + if (reserveDetachHiddenTab.reserved) + clearTimeout(reserveDetachHiddenTab.reserved); + reserveDetachHiddenTab.reserved = setTimeout(async () => { + delete reserveDetachHiddenTab.reserved; + const tabs = new Set(TreeItem.sort(Array.from(reserveDetachHiddenTab.tabs))); + reserveDetachHiddenTab.tabs.clear(); + log('try to detach hidden tabs: ', tabs); + for (const tab of tabs) { + if (!TabsStore.ensureLivingItem(tab)) + continue; + for (const descendant of tab.$TST.descendants) { + if (descendant.hidden) + continue; + const nearestVisibleAncestor = descendant.$TST.ancestors.find(ancestor => !ancestor.hidden && !tabs.has(ancestor)); + if (nearestVisibleAncestor && + nearestVisibleAncestor == descendant.$TST.parent) + continue; + for (const ancestor of descendant.$TST.ancestors) { + if (!ancestor.hidden && + !ancestor.$TST.collapsed) + break; + if (!ancestor.$TST.subtreeCollapsed) + continue; + await Tree.collapseExpandSubtree(ancestor, { + collapsed: false, + broadcast: true + }); + } + if (nearestVisibleAncestor) { + log(` => reattach descendant ${descendant.id} to ${nearestVisibleAncestor.id}`); + await Tree.attachTabTo(descendant, nearestVisibleAncestor, { + dontMove: true, + broadcast: true + }); + } + else { + log(` => detach descendant ${descendant.id}`); + await Tree.detachTab(descendant, { + broadcast: true + }); + } + } + if (tab.$TST.hasParent && + !tab.$TST.parent.hidden) { + log(` => detach hidden tab ${tab.id}`); + await Tree.detachTab(tab, { + broadcast: true + }); + } + } + }, 100); +} +reserveDetachHiddenTab.tabs = new Set(); + +Tab.onHidden.addListener(tab => { + if (configs.fixupTreeOnTabVisibilityChanged) + reserveDetachHiddenTab(tab); +}); + +function reserveAttachShownTab(tab) { + tab.$TST.addState(Constants.kTAB_STATE_SHOWING); + reserveAttachShownTab.tabs.add(tab); + if (reserveAttachShownTab.reserved) + clearTimeout(reserveAttachShownTab.reserved); + reserveAttachShownTab.reserved = setTimeout(async () => { + delete reserveAttachShownTab.reserved; + const tabs = new Set(TreeItem.sort(Array.from(reserveAttachShownTab.tabs))); + reserveAttachShownTab.tabs.clear(); + log('try to attach shown tabs: ', tabs); + for (const tab of tabs) { + if (!TabsStore.ensureLivingItem(tab) || + tab.$TST.hasParent) { + tab.$TST.removeState(Constants.kTAB_STATE_SHOWING); + continue; + } + const referenceTabs = TreeBehavior.calculateReferenceItemsFromInsertionPosition(tab, { + context: Constants.kINSERTION_CONTEXT_SHOWN, + insertAfter: tab.$TST.nearestVisiblePrecedingTab, + // Instead of nearestFollowingForeignerTab, to avoid placing the tab + // after hidden tabs (too far from the target) + insertBefore: tab.$TST.unsafeNearestFollowingForeignerTab + }); + if (referenceTabs.parent) { + log(` => attach shown tab ${tab.id} to ${referenceTabs.parent.id}`); + await Tree.attachTabTo(tab, referenceTabs.parent, { + insertBefore: referenceTabs.insertBefore, + insertAfter: referenceTabs.insertAfter, + broadcast: true + }); + } + tab.$TST.removeState(Constants.kTAB_STATE_SHOWING); + } + }, 100); +} +reserveAttachShownTab.tabs = new Set(); + +Tab.onShown.addListener(tab => { + if (configs.fixupTreeOnTabVisibilityChanged) + reserveAttachShownTab(tab); +}); + +Background.onReady.addListener(() => { + mInitialized = true; +}); + +Tree.onSubtreeCollapsedStateChanged.addListener((tab, _info) => { + if (mInitialized) + TreeStructure.reserveToSaveTreeStructure(tab.windowId); + BackgroundCache.markWindowCacheDirtyFromTab(tab, Constants.kWINDOW_STATE_CACHED_SIDEBAR_COLLAPSED_DIRTY); +}); diff --git a/waterfox/browser/components/sidebar/background/index-ws.js b/waterfox/browser/components/sidebar/background/index-ws.js new file mode 100644 index 000000000000..88de1e28b6a0 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/index-ws.js @@ -0,0 +1,36 @@ +/* +# 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'; + +import './index.js'; + +import { + configs, +} from '/common/common.js'; +import '/common/sync-provider.js'; + +import './auto-sticky-tabs.js'; +import './handle-autoplay-blocking.js'; +import './handle-chrome-menu-commands.js'; +import './prefs.js'; +import './sharing-service.js'; +import * as TabsOpen from './tabs-open.js'; + +browser.waterfoxBridge.initUI(); + +TabsOpen.onForbiddenURLRequested.addListener(url => { + browser.waterfoxBridge.reserveToLoadForbiddenURL(url); +}); + +browser.waterfoxBridge.onSidebarShown.addListener(windowId => { + configs.sidebarVirtuallyClosedWindows = configs.sidebarVirtuallyClosedWindows.filter(id => id != windowId); +}); + +browser.waterfoxBridge.onSidebarHidden.addListener(windowId => { + if (!configs.sidebarVirtuallyClosedWindows.includes(windowId)) + configs.sidebarVirtuallyClosedWindows = [...configs.sidebarVirtuallyClosedWindows, windowId]; +}); + diff --git a/waterfox/browser/components/sidebar/background/index.js b/waterfox/browser/components/sidebar/background/index.js new file mode 100644 index 000000000000..ff99f4538e6e --- /dev/null +++ b/waterfox/browser/components/sidebar/background/index.js @@ -0,0 +1,48 @@ +/* +# 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'; + +import { + log, + configs +} from '/common/common.js'; +import * as SidebarConnection from '/common/sidebar-connection.js'; +import * as TabsStore from '/common/tabs-store.js'; + +import MetricsData from '/common/MetricsData.js'; +import { Tab } from '/common/TreeItem.js'; + +import * as Background from './background.js'; +import './handle-misc.js'; +import './handle-moved-tabs.js'; +import './handle-new-tabs.js'; +import './handle-removed-tabs.js'; +import './handle-tab-bunches.js'; +import './handle-tab-focus.js'; +import './handle-tab-multiselect.js'; +import './handle-tree-changes.js'; +import './sync-background.js'; + +log.context = 'BG'; + +MetricsData.add('index: Loaded'); + +window.addEventListener('DOMContentLoaded', Background.init, { once: true }); + +window.dumpMetricsData = () => { + return MetricsData.toString(); +}; +window.dumpLogs = () => { + return log.logs.join('\n'); +}; + +// for old debugging method +window.log = log; +window.gMetricsData = MetricsData; +window.Tab = Tab; +window.TabsStore = TabsStore; +window.SidebarConnection = SidebarConnection; +window.configs = configs; diff --git a/waterfox/browser/components/sidebar/background/migration.js b/waterfox/browser/components/sidebar/background/migration.js new file mode 100644 index 000000000000..677ce6d4a560 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/migration.js @@ -0,0 +1,505 @@ +/* +# 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'; + +import { + log as internalLogger, + configs, + saveUserStyleRules, + notify, + wait, + isLinux, + isMacOS, +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; +import * as Permissions from '/common/permissions.js'; + +// eslint-disable-next-line no-unused-vars +function log(...args) { + internalLogger('background/migration', ...args); +} + +const kCONFIGS_VERSION = 33; +const kFEATURES_VERSION = 9; + +export function migrateConfigs() { + switch (configs.configsVersion) { + case 0: + case 1: + if (configs.startDragTimeout !== null) + configs.longPressDuration = configs.startDragTimeout; + if (configs.emulateDefaultContextMenu !== null) + configs.emulateDefaultContextMenu = configs.emulateDefaultContextMenu; + + case 2: + if (configs.simulateSelectOwnerOnClose !== null && + !configs.simulateSelectOwnerOnClose) + configs.successorTabControlLevel = Constants.kSUCCESSOR_TAB_CONTROL_NEVER; + + case 3: + if (!(configs.tabDragBehavior & Constants.kDRAG_BEHAVIOR_ALLOW_BOOKMARK)) + configs.tabDragBehavior |= Constants.kDRAG_BEHAVIOR_TEAR_OFF; + if (!(configs.tabDragBehaviorShift & Constants.kDRAG_BEHAVIOR_ALLOW_BOOKMARK)) + configs.tabDragBehaviorShift |= Constants.kDRAG_BEHAVIOR_TEAR_OFF; + + case 4: + if (configs.fakeContextMenu !== null) + configs.emulateDefaultContextMenu = configs.fakeContextMenu; + if (configs.context_closeTabOptions_closeTree !== null) + configs.context_topLevel_closeTree = configs.context_closeTabOptions_closeTree; + if (configs.context_closeTabOptions_closeDescendants !== null) + configs.context_topLevel_closeDescendants = configs.context_closeTabOptions_closeDescendants; + if (configs.context_closeTabOptions_closeOthers !== null) + configs.context_topLevel_closeOthers = configs.context_closeTabOptions_closeOthers; + + case 5: + if (configs.scrollbarMode !== null) { + switch (configs.scrollbarMode < 0 ? (isMacOS() ? 3 : 1) : configs.scrollbarMode) { + case 0: // default, refular width + configs.userStyleRules += ` + +/* regular width scrollbar */ +#tabbar { scrollbar-width: auto; }`; + break; + case 1: // narrow width + break; + case 2: // hide + configs.userStyleRules += ` + +/* hide scrollbar */ +#tabbar { scrollbar-width: none; } + +/* cancel spaces for macOS overlay scrollbar */ +:root.platform-mac #tabbar:dir(rtl).overflow .tab:not(.pinned) { + padding-inline-start: 0; +} +:root.platform-mac #tabbar:dir(ltr).overflow .tab:not(.pinned) { + padding-inline-end: 0; +}`; + break; + case 3: // overlay (macOS) + break; + } + } + if (configs.sidebarScrollbarPosition !== null) { + switch (configs.sidebarScrollbarPosition) { + default: + case 0: // auto + case 1: // left + break; + break; + case 2: // right + configs.userStyleRules += ` + +/* put scrollbar rightside */ +:root.left #tabbar { direction: ltr; }`; + break; + } + } + + case 6: + if (configs.promoteFirstChildForClosedRoot != null && + configs.promoteFirstChildForClosedRoot && + configs.closeParentBehavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN) + configs.closeParentBehavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY; + if (configs.parentTabBehaviorForChanges !== null) { + switch (configs.parentTabBehaviorForChanges) { + case Constants.kPARENT_TAB_BEHAVIOR_ALWAYS: + configs.parentTabOperationBehaviorMode = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_MODE_CONSISTENT; + break; + default: + case Constants.kPARENT_TAB_BEHAVIOR_ONLY_WHEN_VISIBLE: + configs.parentTabOperationBehaviorMode = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_MODE_PARALLEL; + break; + case Constants.kPARENT_TAB_BEHAVIOR_ONLY_ON_SIDEBAR: + configs.parentTabOperationBehaviorMode = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_MODE_CUSTOM; + configs.closeParentBehavior_outsideSidebar_expanded = configs.closeParentBehavior_noSidebar_expanded = configs. closeParentBehavior; + break; + } + } + + case 7: + if (configs.collapseExpandSubtreeByDblClick !== null && + configs.collapseExpandSubtreeByDblClick) + configs.treeDoubleClickBehavior = Constants.kTREE_DOUBLE_CLICK_BEHAVIOR_TOGGLE_COLLAPSED; + + case 8: + if (configs.autoExpandOnCollapsedChildActive !== null) + configs.unfocusableCollapsedTab = configs.autoExpandOnCollapsedChildActive; + + case 9: + if (configs.simulateCloseTabByDblclick !== null && + configs.simulateCloseTabByDblclick) + configs.treeDoubleClickBehavior = Constants.kTREE_DOUBLE_CLICK_BEHAVIOR_CLOSE; + + case 10: + if (configs.style == 'plain-dark' || + configs.style == 'metal') + configs.style = configs.$default.style; + + case 11: + if (configs.userStyleRules) { + configs.userStyleRules0 = configs.userStyleRules; + configs.userStyleRules = ''; + } + + case 12: + try { + saveUserStyleRules(Array.from(new Uint8Array(8), (_, index) => { + const key = `userStyleRules${index}`; + if (key in configs) { + const chunk = configs[key]; + configs[key] = ''; + return chunk || ''; + } + else { + return ''; + } + }).join('')); + } + catch(error) { + console.error(error); + } + + case 13: + if (configs.style == 'mixed' || + configs.style == 'vertigo') + configs.style = 'photon'; + + case 14: + if (configs.inheritContextualIdentityToNewChildTab !== null) + configs.inheritContextualIdentityToChildTabMode = configs.inheritContextualIdentityToNewChildTab ? Constants.kCONTEXTUAL_IDENTITY_FROM_PARENT : Constants.kCONTEXTUAL_IDENTITY_DEFAULT; + if (configs.inheritContextualIdentityToSameSiteOrphan !== null) + configs.inheritContextualIdentityToSameSiteOrphanMode = configs.inheritContextualIdentityToSameSiteOrphan ? Constants.kCONTEXTUAL_IDENTITY_FROM_LAST_ACTIVE : Constants.kCONTEXTUAL_IDENTITY_DEFAULT; + if (configs.inheritContextualIdentityToTabsFromExternal !== null) + configs.inheritContextualIdentityToTabsFromExternalMode = configs.inheritContextualIdentityToTabsFromExternal ? Constants.kCONTEXTUAL_IDENTITY_FROM_PARENT : Constants.kCONTEXTUAL_IDENTITY_DEFAULT; + + case 15: + if (configs.moveDroppedTabToNewWindowForUnhandledDragEvent !== null && + !configs.moveDroppedTabToNewWindowForUnhandledDragEvent) { + if (configs.tabDragBehavior & Constants.kDRAG_BEHAVIOR_TEAR_OFF) + configs.tabDragBehavior = configs.tabDragBehavior ^ Constants.kDRAG_BEHAVIOR_TEAR_OFF; + else if (configs.tabDragBehavior & Constants.kDRAG_BEHAVIOR_ALLOW_BOOKMARK) + configs.tabDragBehavior = configs.tabDragBehavior ^ Constants.kDRAG_BEHAVIOR_ALLOW_BOOKMARK; + + if (configs.tabDragBehaviorShift & Constants.kDRAG_BEHAVIOR_TEAR_OFF) + configs.tabDragBehaviorShift = configs.tabDragBehaviorShift ^ Constants.kDRAG_BEHAVIOR_TEAR_OFF; + else if (configs.tabDragBehaviorShift & Constants.kDRAG_BEHAVIOR_ALLOW_BOOKMARK) + configs.tabDragBehaviorShift = configs.tabDragBehaviorShift ^ Constants.kDRAG_BEHAVIOR_ALLOW_BOOKMARK; + } + + case 16: + configs.maximumDelayForBug1561879 = Math.max(configs.$default.maximumDelayForBug1561879, configs.maximumDelayForBug1561879); + + case 17: + configs.tabDragBehavior |= Constants.kDRAG_BEHAVIOR_MOVE; + configs.tabDragBehaviorShift |= Constants.kDRAG_BEHAVIOR_MOVE; + + case 18: + if (configs.connectionTimeoutDelay == 5000) + configs.connectionTimeoutDelay = configs.$default.connectionTimeoutDelay; + + case 19: + if (configs.suppressGapFromShownOrHiddenToolbar !== null) { + configs.suppressGapFromShownOrHiddenToolbarOnNewTab = + configs.suppressGapFromShownOrHiddenToolbarOnFullScreen = configs.suppressGapFromShownOrHiddenToolbar; + } + + case 20: + if (configs.treatTreeAsExpandedOnClosedWithNoSidebar !== null) { + configs.treatTreeAsExpandedOnClosed_noSidebar = configs.treatTreeAsExpandedOnClosedWithNoSidebar; + } + + case 21: + if (configs.style == 'plain') + configs.style = 'photon'; + + case 22: + case 23: + if (configs.closeParentBehaviorMode !== null) { + configs.parentTabOperationBehaviorMode = configs.closeParentBehaviorMode; + } + if (configs.closeParentBehavior !== null) { + configs.closeParentBehavior_insideSidebar_expanded = + configs.closeParentBehavior; + } + if (configs.closeParentBehavior_outsideSidebar !== null) { + configs.closeParentBehavior_outsideSidebar_expanded = + configs.moveParentBehavior_outsideSidebar_expanded = + configs.closeParentBehavior_outsideSidebar; + } + if (configs.closeParentBehavior_noSidebar !== null) { + configs.closeParentBehavior_noSidebar_expanded = + configs.closeParentBehavior_noSidebar_expanded = + configs.closeParentBehavior_noSidebar; + } + if (configs.treatTreeAsExpandedOnClosed_outsideSidebar === true) { + configs.closeParentBehavior_outsideSidebar_collapsed = + configs.moveParentBehavior_outsideSidebar_collapsed = + configs.moveParentBehavior_outsideSidebar_expanded; + } + if (configs.treatTreeAsExpandedOnClosed_noSidebar === false) { + configs.closeParentBehavior_noSidebar_collapsed = + configs.moveParentBehavior_noSidebar_collapsed = + Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE; + } + if (configs.treatTreeAsExpandedOnClosed_outsideSidebar === true || + configs.treatTreeAsExpandedOnClosed_noSidebar === false) { + configs.parentTabOperationBehaviorMode = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_MODE_CUSTOM; + } + + case 24: + if (configs.autoGroupNewTabsTimeout !== null) + configs.tabBunchesDetectionTimeout = configs.autoGroupNewTabsTimeout; + if (configs.autoGroupNewTabsDelayOnNewWindow !== null) + configs.tabBunchesDetectionDelayOnNewWindow = configs.autoGroupNewTabsDelayOnNewWindow; + + case 25: + if (configs.autoHiddenScrollbarPlaceholderSize !== null) + configs.shiftTabsForScrollbarDistance = configs.autoHiddenScrollbarPlaceholderSize; + + case 26: + if (!configs.guessNewOrphanTabAsOpenedByNewTabCommandUrl.includes('about:privatebrowsing')) + configs.guessNewOrphanTabAsOpenedByNewTabCommandUrl = `${configs.guessNewOrphanTabAsOpenedByNewTabCommandUrl.trim().replace(/\|$/, '')}|about:privatebrowsing`; + + case 27: + if (configs.openAllBookmarksWithGroupAlways !== null) + configs.suppressGroupTabForStructuredTabsFromBookmarks = !configs.openAllBookmarksWithGroupAlways; + + case 28: + if (configs.heartbeatInterval == 1000) + configs.heartbeatInterval = configs.$default.heartbeatInterval; + + case 29: + const autoAttachKeys = [ + 'autoAttachOnOpenedWithOwner', + 'autoAttachOnNewTabCommand', + 'autoAttachOnContextNewTabCommand', + 'autoAttachOnNewTabButtonMiddleClick', + 'autoAttachOnNewTabButtonAccelClick', + 'autoAttachOnDuplicated', + 'autoAttachSameSiteOrphan', + 'autoAttachOnOpenedFromExternal', + 'autoAttachOnAnyOtherTrigger', + ]; + if (configs.insertNewChildAt != configs.$default.insertNewChildAt && + (configs.insertNewChildAt == Constants.kINSERT_TOP || + configs.insertNewChildAt == Constants.kINSERT_END)) { + for (const key of autoAttachKeys) { + if (configs[key] != configs.$default[key] && + configs[key] != Constants.kNEWTAB_OPEN_AS_CHILD) + continue; + if (configs.insertNewChildAt == Constants.kINSERT_TOP) + configs[key] = Constants.kNEWTAB_OPEN_AS_CHILD_TOP; + else + configs[key] = Constants.kNEWTAB_OPEN_AS_CHILD_END; + } + } + else { + for (const key of autoAttachKeys) { + if (configs[key] == Constants.kNEWTAB_OPEN_AS_CHILD) + configs[key] = Constants.kNEWTAB_OPEN_AS_CHILD_TOP; + } + } + + case 30: + browser.windows.getAll().then(windows => { + for (const win of windows) { + browser.sessions.removeWindowValue(win.id, Constants.kWINDOW_STATE_CACHED_SIDEBAR); + browser.sessions.removeWindowValue(win.id, Constants.kWINDOW_STATE_CACHED_SIDEBAR_TABS_DIRTY); + browser.sessions.removeWindowValue(win.id, Constants.kWINDOW_STATE_CACHED_SIDEBAR_COLLAPSED_DIRTY); + } + }); + + case 31: + if (configs.tabPreviewTooltipInSidebar !== null) + configs.tabPreviewTooltipRenderIn = configs.tabPreviewTooltipInSidebar ? + Constants.kIN_CONTENT_PANEL_RENDER_IN_ANYWHERE : + Constants.kIN_CONTENT_PANEL_RENDER_IN_CONTENT; + + case 32: + if (configs.tabPreviewTooltipOffsetTop !== null) + configs.inContentUIOffsetTop = configs.tabPreviewTooltipOffsetTop; + } + configs.configsVersion = kCONFIGS_VERSION; +} + +let mShouldShowInitialStartupPage = false; + +export function tryNotifyNewFeatures() { + /* + let featuresVersionOffset = 0; + const browserInfo = await browser.runtime.getBrowserInfo().catch(ApiTabs.createErrorHandler()); + // "search" permission becomes available! + if (parseInt(browserInfo.version.split('.')[0]) >= 63) + featuresVersionOffset++; + // "menus.overrideContext" permission becomes available! + if (parseInt(browserInfo.version.split('.')[0]) >= 64) + featuresVersionOffset++; + */ + + const featuresVersion = kFEATURES_VERSION /*+ featuresVersionOffset*/; + const isInitialInstall = configs.notifiedFeaturesVersion == 0; + + if (configs.notifiedFeaturesVersion >= featuresVersion) + return; + configs.notifiedFeaturesVersion = featuresVersion; + + if (isInitialInstall && + !configs.syncOtherDevicesDetected && + Object.keys(configs.syncDevices).length > 1) { + configs.syncAvailableNotified = true; + configs.syncOtherDevicesDetected = true; + } + + const typeSuffix = isInitialInstall ? 'installed' : 'updated'; + const platformSuffix = isLinux() ? '_linux' : ''; + const url = isInitialInstall ? Constants.kSHORTHAND_URIS.startup : browser.i18n.getMessage('message_startup_history_uri'); + notify({ + url, + title: browser.i18n.getMessage(`startup_notification_title_${typeSuffix}`), + message: browser.i18n.getMessage(`startup_notification_message_${typeSuffix}${platformSuffix}`), + timeout: 90 * 1000 + }); + + if (isInitialInstall) { + mShouldShowInitialStartupPage = true; + browser.browserAction.setBadgeText({ + text: '!', + }); + } +} + +export function isInitialStartup() { + return !!mShouldShowInitialStartupPage; +} + +export function openInitialStartupPage() { + mShouldShowInitialStartupPage = false; + browser.browserAction.setBadgeText({ + text: null, + }); + browser.tabs.create({ + url: Constants.kSHORTHAND_URIS.startup, + active: true, + }); +} + + +// Auto-migration of bookmarked internal URLs +// +// Internal URLs like "moz-extension://(UUID)/..." are runtime environment +// dependent and unavailable when such bookmarks are loaded in different +// runtime environment, for example they are synchronized from other devices. +// Thus we should migrate such internal URLs to universal shorthand URIs like +// "ext+ws:(name)". + +export async function migrateBookmarkUrls() { + const granted = await Permissions.isGranted(Permissions.BOOKMARKS); + if (!granted) + return; + + startBookmarksUrlAutoMigration(); + + const urls = new Set(configs.migratedBookmarkUrls); + const migrations = []; + const updates = []; + for (const key in Constants.kSHORTHAND_URIS) { + const url = Constants.kSHORTHAND_URIS[key].split('?')[0]; + if (urls.has(url)) + continue; + + const shorthand = `ext+ws:${key.toLowerCase()}`; + migrations.push(browser.bookmarks.search({ query: url }) + .then(bookmarks => { + for (const bookmark of bookmarks) { + updates.push(browser.bookmarks.update(bookmark.id, { + url: bookmark.url.replace(url, shorthand) + })); + } + })); + urls.add(url); + } + if (migrations.length > 0) + await Promise.all(migrations); + if (updates.length > 0) + await Promise.all(updates); + if (urls.size > configs.migratedBookmarkUrls.length) + configs.migratedBookmarkUrls = Array.from(urls); +} + +async function migrateBookmarkUrl(bookmark) { + for (const key in Constants.kSHORTHAND_URIS) { + const url = Constants.kSHORTHAND_URIS[key].split('?')[0]; + if (!bookmark.url.startsWith(url)) + continue; + + const shorthand = `ext+ws:${key.toLowerCase()}`; + return browser.bookmarks.update(bookmark.id, { + url: bookmark.url.replace(url, shorthand) + }); + } +} + +let mObservingBookmarks = false; +let mBookmarksListenersRegistered = false; + +function onBookmarkCreated(id, bookmark) { + if (!mObservingBookmarks) + return; + + if (bookmark.url) + migrateBookmarkUrl(bookmark); +} + +async function onBookmarkChanged(id, changeInfo) { + if (!mObservingBookmarks) + return; + + if (changeInfo.url && + changeInfo.url.startsWith(browser.runtime.getURL(''))) { + const bookmark = await browser.bookmarks.get(id); + if (Array.isArray(bookmark)) + bookmark.forEach(migrateBookmarkUrl); + else + migrateBookmarkUrl(bookmark); + } +} + +if (browser.bookmarks && + browser.bookmarks.onCreated) { + browser.bookmarks.onCreated.addListener(onBookmarkCreated); + browser.bookmarks.onChanged.addListener(onBookmarkChanged); + mBookmarksListenersRegistered = true; +} + +async function startBookmarksUrlAutoMigration() { + if (mObservingBookmarks) + return; + + mObservingBookmarks = true; + + if (mBookmarksListenersRegistered || + !browser.bookmarks || + !browser.bookmarks.onCreated) + return; + + browser.bookmarks.onCreated.addListener(onBookmarkCreated); + browser.bookmarks.onChanged.addListener(onBookmarkChanged); + mBookmarksListenersRegistered = true; +} + + +configs.$loaded.then(() => { + configs.$addObserver(async key => { + // This may be triggered not only "reset all", but while importing of configs also. + // We should try initial migration after all configs are successfully imported. + await wait(100); + if (key == 'configsVersion' && + configs.configsVersion == 0) + migrateConfigs(); + }); +}); diff --git a/waterfox/browser/components/sidebar/background/native-tab-groups.js b/waterfox/browser/components/sidebar/background/native-tab-groups.js new file mode 100644 index 000000000000..3ec208ab7c9b --- /dev/null +++ b/waterfox/browser/components/sidebar/background/native-tab-groups.js @@ -0,0 +1,439 @@ +/* +# 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'; + +import { + log as internalLogger, + wait, + configs, + shouldApplyAnimation, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TreeBehavior from '/common/tree-behavior.js'; + +import { Tab, TabGroup, TreeItem } from '/common/TreeItem.js'; + +import * as Tree from './tree.js'; + +function log(...args) { + internalLogger('background/native-tab-groups', ...args); +} + +export const internallyMovingNativeTabGroups = new Map(); + +export async function addTabsToGroup(tabs, groupIdOrProperties) { + const initialGroupId = typeof groupIdOrProperties == 'number' ? groupIdOrProperties : null; + const groupId = await addTabsToGroupInternal(tabs, groupIdOrProperties); + const created = groupId != initialGroupId; + return { groupId, created }; +} +async function addTabsToGroupInternal(tabs, groupIdOrProperties) { + let groupId = typeof groupIdOrProperties == 'number' ? groupIdOrProperties : null; + const tabsToGrouped = tabs.filter(tab => tab.groupId != groupId); + if (tabsToGrouped.length == 0) { + return groupId; + } + + log('addTabsToGroupInternal ', tabsToGrouped, groupId, groupIdOrProperties); + + const pinnedTabs = tabsToGrouped.filter(tab => tab.pinned); + if (pinnedTabs.length > 0) { + await Promise.all( + pinnedTabs.map( + tab => browser.tabs.update(tab.id, { pinned: false }) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)) + ) + ); + } + + const windowId = tabsToGrouped[0].windowId; + const structure = TreeBehavior.getTreeStructureFromTabs(tabsToGrouped); + + await Tree.detachTabsFromTree(tabsToGrouped, { + fromParent: true, + partial: true, + }); + + const { promisedGrouped, finish } = waitUntilGrouped(tabsToGrouped, { + groupId, + windowId, + }); + log('addTabsToGroupInternal: group tabs!'); + await browser.tabs.group({ + groupId, + tabIds: tabsToGrouped.map(tab => tab.id), + ...(groupId ? {} : { + createProperties: { + windowId, // We must specify the window ID explicitly, otherwise tabs moved across windows may be reverted and grouped in the old window! + }, + }) + }); + const group = await promisedGrouped; + groupId = group.id; + log('addTabsToGroupInternal: => ', group); + + for (const tab of tabsToGrouped) { + TabsStore.addNativelyGroupedTab(tab, group.windowId); + } + + if (groupIdOrProperties && + typeof groupIdOrProperties == 'object') { + log('addTabsToGroupInternal: applying group properties'); + const updateProperties = {}; + if ('title' in groupIdOrProperties) { + updateProperties.title = groupIdOrProperties.title; + } + if ('color' in groupIdOrProperties) { + updateProperties.color = groupIdOrProperties.color; + } + if ('collapsed' in groupIdOrProperties) { + updateProperties.collapsed = groupIdOrProperties.collapsed; + } + await browser.tabGroups.update(groupId, updateProperties); + } + + finish(); + + await rejectGroupFromTree(group); + + log('addTabsToGroupInternal: applying tree structure'); + await Tree.applyTreeStructureToTabs(tabsToGrouped, structure, { + broadcast: true + }); + + return groupId; +} + +export async function rejectGroupFromTree(group) { + if (!group) { + return; + } + group = TabGroup.get(group.id); + if (!group?.$TST) { + log('rejectGroupFromTree: failed to reject untracked group'); + return; + } + + const firstMember = group.$TST.firstMember; + const lastMember = group.$TST.lastMember; + const prevTab = firstMember?.$TST.previousTab; + const nextTab = lastMember?.$TST.nextTab; + const rootTab = prevTab?.$TST.rootTab; + if (!prevTab || + !nextTab || + prevTab.groupId != nextTab.groupId || + prevTab.groupId != -1 || + rootTab != nextTab.$TST.rootTab) { + log('rejectGroupFromTree: no need to reject from tree'); + return; + } + + log('rejectGroupFromTree ', group.id); + await Tree.detachTabsFromTree(group.$TST.members, { + fromParent: true, + partial: true, + }); + + // The group is in a middle of a tree. We need to move the new group away from the tree. + const lastDescendant = rootTab.$TST.lastDescendant; + if (firstMember.index - rootTab.index <= lastDescendant.index - lastMember.index) { // move above the tree + log('rejectGroupFromTree: move ', group.id, ' before ', rootTab.id); + await moveGroupBefore(group, rootTab); + } + else { // move below the tree + log('rejectGroupFromTree: move ', group.id, ' after ', lastDescendant.id); + await moveGroupAfter(group, lastDescendant); + } +} + +function waitUntilGrouped(tabs, { groupId, windowId } = {}) { + const toBeGroupedIds = tabs.map(tab => tab.id); + const win = TabsStore.windows.get(windowId || tabs[0].windowId); + + for (const tab of tabs) { + win.internallyMovingTabsForUpdatedNativeTabGroups.add(tab.id); + win.internalMovingTabs.set(tab.id, -1); + } + + let onUpdated = null; + const { promisedMoved, finish: finishMoved } = waitUntilMoved(tabs, win.id) + const promisedGrouped = new Promise((resolve, _reject) => { + if (!groupId) { + const onGroupCreated = group => { + groupId = group.id; + browser.tabGroups.onCreated.removeListener(onGroupCreated); + }; + browser.tabGroups.onCreated.addListener(onGroupCreated); + } + const toBeGroupedIdsSet = new Set(toBeGroupedIds); + onUpdated = (tabId, changeInfo, _tab) => { + if (changeInfo.groupId == groupId) { + toBeGroupedIdsSet.delete(tabId); + win.internallyMovingTabsForUpdatedNativeTabGroups.delete(tabId); + } + if (toBeGroupedIdsSet.size == 0) { + resolve(changeInfo.groupId); + } + }; + browser.tabs.onUpdated.addListener(onUpdated, { properties: ['groupId'] }); + }); + + const finish = () => { + if (finish.done) { + return; + } + browser.tabs.onUpdated.removeListener(onUpdated); + for (const tab of tabs) { + win.internalMovingTabs.delete(tab.id); + } + finish.done = true; + }; + + return { + promisedGrouped: Promise.all([ + promisedGrouped, + Promise.race([ + promisedMoved, + wait(configs.nativeTabGroupModificationDetectionTimeoutAfterTabMove), + ]), + ]).then(([groupId]) => { + finish(); + finishMoved(); + return TabGroup.get(groupId); + }), + finish, + }; +} + +export async function removeTabsFromGroup(tabs) { + const tabsToBeUngrouped = tabs.filter(tab => tab.groupId != -1); + if (tabsToBeUngrouped.length == 0) { + return; + } + const win = TabsStore.windows.get(tabs[0].windowId); + for (const tab of tabs) { + win.internallyMovingTabsForUpdatedNativeTabGroups.add(tab.id); + win.internalMovingTabs.set(tab.id, -1); + } + const toBeUngroupedIds = tabsToBeUngrouped.map(tab => tab.id); + let onUpdated = null; + await new Promise((resolve, _reject) => { + const toBeUngroupedIdsSet = new Set(toBeUngroupedIds); + onUpdated = (tabId, changeInfo, _tab) => { + if (changeInfo.groupId == -1) { + toBeUngroupedIdsSet.delete(tabId); + win.internallyMovingTabsForUpdatedNativeTabGroups.delete(tabId); + } + if (toBeUngroupedIdsSet.size == 0) { + resolve(); + } + }; + browser.tabs.onUpdated.addListener(onUpdated, { properties: ['groupId'] }); + browser.tabs.ungroup(toBeUngroupedIds); + }); + for (const tab of tabsToBeUngrouped) { + win.internalMovingTabs.delete(tab.id); + TabsStore.removeNativelyGroupedTab(tab, win.id); + } + browser.tabs.onUpdated.removeListener(onUpdated); +} + +export async function matchTabsGrouped(tabs, groupIdOrCreateParams) { + if (groupIdOrCreateParams == -1) { + await removeTabsFromGroup(tabs); + } + else { + await addTabsToGroup(tabs, groupIdOrCreateParams); + } +} + +export async function moveGroupToNewWindow({ groupId, windowId, duplicate, left, top }) { + log('moveGroupToNewWindow: ', groupId, windowId); + const group = TabGroup.get(groupId); + const members = group.$TST.members; + const movedTabs = await Tree.openNewWindowFromTabs(members, { duplicate, left, top }); + await addTabsToGroupInternal(movedTabs, { + title: group.title, + color: group.color, + }); +} + +export async function moveGroupBefore(group, insertBefore) { + log('moveGroupBefore: ', group, insertBefore); + const beforeCount = internallyMovingNativeTabGroups.get(group.id) || 0; + internallyMovingNativeTabGroups.set(group.id, beforeCount + 1); + + const { promisedMoved, finish } = waitUntilMoved(group, insertBefore.windowId); + + if (insertBefore.type == TreeItem.TYPE_GROUP) { + insertBefore = insertBefore.$TST.firstMember; + } + + const members = group.$TST.members; + const firstMember = group.$TST.firstMember; + const delta = insertBefore.windowId == group.windowId && insertBefore.index > firstMember.index ? members.length : 0; + const index = insertBefore.index - delta; + log('moveGroupBefore: move to ', index, { delta, insertBeforeIndex: insertBefore.index }); + await browser.tabGroups.move(group.id, { + index, + windowId: insertBefore.windowId, + }); + + await Promise.race([ + promisedMoved, + wait(configs.nativeTabGroupModificationDetectionTimeoutAfterTabMove).then(() => { + if (finish.done) { + return; + } + }), + ]); + finish(); + + const afterCount = internallyMovingNativeTabGroups.get(group.id) || 0; + if (afterCount <= 1) { + internallyMovingNativeTabGroups.delete(group.id); + } + else { + internallyMovingNativeTabGroups.set(group.id, afterCount - 1); + } + log('moveGroupBefore: finish'); +} + +export async function moveGroupAfter(group, insertAfter) { + log('moveGroupAfter: ', group, insertAfter); + const beforeCount = internallyMovingNativeTabGroups.get(group.id) || 0; + internallyMovingNativeTabGroups.set(group.id, beforeCount + 1); + + const { promisedMoved, finish } = waitUntilMoved(group, insertAfter.windowId); + + if (insertAfter.type == TreeItem.TYPE_GROUP) { + if (insertAfter.collapsed) { + insertAfter = insertAfter.$TST.lastMember; + } + else { + return moveGroupBefore(group, insertAfter.$TST.firstMember); + } + } + + const members = group.$TST.members; + const firstMember = group.$TST.firstMember; + const delta = insertAfter.windowId == group.windowId && insertAfter.index > firstMember.index ? members.length : 0; + const index = insertAfter.index + 1 - delta; + log('moveGroupAfter: move to ', index, { delta, insertAfterIndex: insertAfter.index }); + await browser.tabGroups.move(group.id, { + index, + windowId: insertAfter.windowId, + }); + + await Promise.race([ + promisedMoved, + wait(configs.nativeTabGroupModificationDetectionTimeoutAfterTabMove).then(() => { + if (finish.done) { + return; + } + }), + ]); + finish(); + + const afterCount = internallyMovingNativeTabGroups.get(group.id) || 0; + if (afterCount <= 1) { + internallyMovingNativeTabGroups.delete(group.id); + } + else { + internallyMovingNativeTabGroups.set(group.id, afterCount - 1); + } + log('moveGroupAfter: finish'); +} + +export function waitUntilMoved(groupOrMembers, destinationWindowId) { + const members = Array.isArray(groupOrMembers) ? + groupOrMembers : + groupOrMembers.$TST.members; + const win = TabsStore.windows.get(destinationWindowId || members[0].windowId); + const toBeMovedTabs = new Set(); + for (const tab of members) { + toBeMovedTabs.add(tab.id); + win.internalMovingTabs.set(tab.id, -1); + } + let onTabMoved; + const promisedMoved = new Promise((resolve, _reject) => { + onTabMoved = (tabId, _moveInfo) => { + if (toBeMovedTabs.has(tabId)) { + toBeMovedTabs.delete(tabId); + } + if (toBeMovedTabs.size == 0) { + log('waitUntilMoved: all members have been moved'); + resolve(); + } + }; + browser.tabs.onMoved.addListener(onTabMoved); + }); + const finish = () => { + if (finish.done) { + return; + } + browser.tabs.onMoved.removeListener(onTabMoved); + for (const tab of members) { + win.internalMovingTabs.delete(tab.id); + } + finish.done = true; + }; + return { + promisedMoved: promisedMoved.then(finish), + finish, + }; +} + + +function reserveToMaintainTreeForGroup(groupId, options = {}) { + let timer = reserveToMaintainTreeForGroup.delayed.get(groupId); + if (timer) + clearTimeout(timer); + if (options.justNow || !shouldApplyAnimation()) { + const group = TabGroup.get(groupId); + rejectGroupFromTree(group); + } + timer = setTimeout(() => { + reserveToMaintainTreeForGroup.delayed.delete(groupId); + const group = TabGroup.get(groupId); + rejectGroupFromTree(group); + }, configs.nativeTabGroupModificationDetectionTimeoutAfterTabMove); + reserveToMaintainTreeForGroup.delayed.set(groupId, timer); +} +reserveToMaintainTreeForGroup.delayed = new Map(); + +export async function startToMaintainTree() { + // fixup mismatched tree structure and tab groups constructed while TST is disabled + const groups = await browser.tabGroups.query({}); + for (const group of groups) { + await rejectGroupFromTree(TabGroup.get(group.id)); + } + + // after all we start tracking of dynamic changes of tab groups + + browser.tabGroups.onMoved.addListener(group => { + group = TabGroup.get(group.id); + if (!group) { + return; + } + log('detected tab group move: ', group); + const internalMoveCount = internallyMovingNativeTabGroups.get(group.id); + if (internalMoveCount) { + log(' => ignore internal move ', internalMoveCount); + return; + } + reserveToMaintainTreeForGroup(group.id); + }); + + Tab.onNativeGroupModified.addListener(tab => { + const win = TabsStore.windows.get(tab.windowId); + if (win.internallyMovingTabsForUpdatedNativeTabGroups.has(tab.id)) { + return; + } + reserveToMaintainTreeForGroup(tab.groupId); + }); +} diff --git a/waterfox/browser/components/sidebar/background/prefs.js b/waterfox/browser/components/sidebar/background/prefs.js new file mode 100644 index 000000000000..7ae0b866800a --- /dev/null +++ b/waterfox/browser/components/sidebar/background/prefs.js @@ -0,0 +1,100 @@ +/* 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'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; + +import { + configs, + obsoleteConfigs, +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; + +export const onChanged = new EventListenerManager(); + +if (Constants.IS_BACKGROUND) { + for (const [name, value] of Object.entries(configs.$default)) { + if (obsoleteConfigs.has(name)) + continue; + switch (typeof value) { + case 'boolean': + browser.prefs.setDefaultBoolValue(name, value); + break; + + case 'string': + browser.prefs.setDefaultStringValue(name, value); + break; + + default: + browser.prefs.setDefaultStringValue(name, JSON.stringify(value)); + break; + } + + getPref(name).then(value => { + if (JSON.stringify(configs[name]) == JSON.stringify(value)) + return; + configs[name] = value; + }); + } +} + +async function getPref(name) { + const defaultValue = configs.$default[name]; + switch (typeof defaultValue) { + case 'boolean': + return browser.prefs.getBoolValue(name, defaultValue); + + case 'string': + return browser.prefs.getStringValue(name, defaultValue); + + default: + return browser.prefs.getStringValue(name, JSON.stringify(defaultValue)).then(value => JSON.parse(value)); + } +} + +const mNamesSyncToChrome = new Set(); +const mNamesSyncFromChrome = new Set(); + +browser.prefs.onChanged.addListener(name => { + if (mNamesSyncToChrome.has(name) || + mNamesSyncFromChrome.has(name)) + return; + + mNamesSyncFromChrome.add(name); + getPref(name) + .then(value => { + configs[name] = value; + window.requestAnimationFrame(() => { + mNamesSyncFromChrome.delete(name); + }); + }); +}); + +configs.$addObserver(async name => { + if (mNamesSyncToChrome.has(name) || + mNamesSyncFromChrome.has(name)) + return; + + mNamesSyncToChrome.add(name); + + const value = configs[name]; + const defaultValue = configs.$default[name]; + switch (typeof defaultValue) { + case 'boolean': + await browser.prefs.setBoolValue(name, value); + break; + + case 'string': + await browser.prefs.setStringValue(name, value); + break; + + default: + await browser.prefs.setStringValue(name, JSON.stringify(value)); + break; + } + + window.requestAnimationFrame(() => { + mNamesSyncToChrome.delete(name); + }); +}); diff --git a/waterfox/browser/components/sidebar/background/sharing-service.js b/waterfox/browser/components/sidebar/background/sharing-service.js new file mode 100644 index 000000000000..441f214194c3 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/sharing-service.js @@ -0,0 +1,22 @@ +/* +# 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'; + +import * as TabContextMenu from './tab-context-menu.js'; + +TabContextMenu.registerSharingService({ + async listServices(tab) { + return browser.waterfoxBridge.listSharingServices(tab.id); + }, + + share(tab, shareName = null) { + return browser.waterfoxBridge.share(tab.id, shareName); + }, + + openPreferences() { + return browser.waterfoxBridge.openSharingPreferences(); + }, +}); diff --git a/waterfox/browser/components/sidebar/background/successor-tab.js b/waterfox/browser/components/sidebar/background/successor-tab.js new file mode 100644 index 000000000000..ae1e7f143456 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/successor-tab.js @@ -0,0 +1,467 @@ +/* +# 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'; + +import { + log as internalLogger, + dumpTab, + configs, + wait, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from '/common/constants.js'; +import * as SidebarConnection from '/common/sidebar-connection.js'; +import * as TabsInternalOperation from '/common/tabs-internal-operation.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TreeBehavior from '/common/tree-behavior.js'; + +import { Tab } from '/common/TreeItem.js'; + +import * as Tree from './tree.js'; + +function log(...args) { + internalLogger('background/successor-tab', ...args); +} + +const mTabsToBeUpdated = new Set(); +const mInProgressUpdates = new Set(); + +const mPromisedUpdatedSuccessorTabId = new Map(); + +browser.tabs.onUpdated.addListener((tabId, updateInfo, _tab) => { + if (!('successorTabId' in updateInfo) || + !mPromisedUpdatedSuccessorTabId.has(tabId)) + return; + const promisedUpdate = mPromisedUpdatedSuccessorTabId.get(tabId); + mPromisedUpdatedSuccessorTabId.delete(tabId); + promisedUpdate.resolver(updateInfo.successorTabId); +}, { + // we cannot watch only the property... + // properties: ['successorTabId'], +}); + +TabsInternalOperation.onBeforeTabsRemove.addListener(async tabs => { + let activeTab = null; + const tabIds = tabs.map(tab => { + if (tab.active) + activeTab = tab; + return tab.id; + }); + if (activeTab) + await updateInternal(activeTab.id, tabIds); +}); + +function setSuccessor(tabId, successorTabId = -1) { + const tab = Tab.get(tabId); + const successorTab = Tab.get(successorTabId); + if (configs.successorTabControlLevel == Constants.kSUCCESSOR_TAB_CONTROL_NEVER || + !tab || + !successorTab || + tab.windowId != successorTab.windowId) + return; + + const promisedUpdate = {}; + promisedUpdate.promisedSuccessorTabId = new Promise((resolve, _reject) => { + promisedUpdate.resolver = resolve; + setTimeout(() => { + if (!mPromisedUpdatedSuccessorTabId.has(tabId)) + return; + mPromisedUpdatedSuccessorTabId.delete(tabId); + resolve(null); + }, 2000); + }); + mPromisedUpdatedSuccessorTabId.set(tabId, promisedUpdate); + + const initialSuccessorTabId = tab.successorTabId; + browser.tabs.update(tabId, { + successorTabId + }).then(async () => { + // tabs.onUpdated listener won't be called sometimes, so this is a failsafe. + while (true) { + const promisedUpdate = mPromisedUpdatedSuccessorTabId.get(tabId); + if (!promisedUpdate) + break; + + const tab = await browser.tabs.get(tabId); + if (tab.successorTabId == initialSuccessorTabId && + tab.successorTabId != successorTabId) { + await wait(200); + continue; + } + + mPromisedUpdatedSuccessorTabId.delete(tabId); + promisedUpdate.resolver(tab.successorTabId); + } + }).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError, error => { + // ignore error for already closed tab + if (!error || + !error.message || + (error.message.indexOf('Invalid successorTabId') != 0 && + // This error may happen at the time just after a tab is detached from its original window. + error.message.indexOf('Successor tab must be in the same window as the tab being updated') != 0)) + throw error; + })); +} + +function clearSuccessor(tabId) { + setSuccessor(tabId, -1); +} + + +function update(tabId) { + mTabsToBeUpdated.add(tabId); + if (mInProgressUpdates.size == 0) { + const waitingUpdate = new Promise((resolve, _reject) => { + const timer = setInterval(() => { + if (mInProgressUpdates.size > 1) + return; + clearInterval(timer); + resolve(); + }, 100); + }); + waitingUpdate.catch(_error => {}).then(() => mInProgressUpdates.delete(waitingUpdate)); + mInProgressUpdates.add(waitingUpdate); + } + setTimeout(() => { + const ids = Array.from(mTabsToBeUpdated); + mTabsToBeUpdated.clear(); + for (const id of ids) { + if (!id) + continue; + try { + const promisedUpdate = updateInternal(id); + const promisedUpdateWithTimeout = new Promise((resolve, reject) => { + promisedUpdate.then(resolve).catch(reject); + setTimeout(resolve, 1000); + }); + mInProgressUpdates.add(promisedUpdateWithTimeout); + promisedUpdateWithTimeout.catch(_error => {}).then(() => mInProgressUpdates.delete(promisedUpdateWithTimeout)); + } + catch(_error) { + } + } + }, 0); +} +async function updateInternal(tabId, excludeTabIds = []) { + // tabs.onActivated can be notified before the tab is completely tracked... + await Tab.waitUntilTracked(tabId); + const tab = Tab.get(tabId); + if (!tab) + return; + + const promisedUpdate = mPromisedUpdatedSuccessorTabId.get(tabId); + await Promise.all([ + tab.$TST.opened, + promisedUpdate?.promisedSuccessorTabId, + ]); + + const renewedTab = await browser.tabs.get(tabId).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + if (!renewedTab || + !TabsStore.ensureLivingItem(tab)) + return; + log('updateInternal: ', dumpTab(tab), { + tabSuccessorTabId: tab.successorTabId, + renewedSuccessorTabId: renewedTab.successorTabId, + lastSuccessorTabIdByOwner: tab.$TST.temporaryMetadata.get('lastSuccessorTabIdByOwner'), + lastSuccessorTabId: tab.$TST.temporaryMetadata.get('lastSuccessorTabId'), + }); + if (tab.$TST.temporaryMetadata.has('lastSuccessorTabIdByOwner')) { + log('respect last successor by owner'); + const successor = Tab.get(renewedTab.successorTabId); + if (successor) { + log(` ${dumpTab(tab)} is already prepared for "selectOwnerOnClose" behavior (successor=${renewedTab.successorTabId})`); + return; + } + log(` clear successor of ${dumpTab(tab)}`); + tab.$TST.temporaryMetadata.delete('lastSuccessorTabIdByOwner'); + tab.$TST.temporaryMetadata.delete('lastSuccessorTabId'); + clearSuccessor(tab.id); + } + const lastSuccessorTab = tab.$TST.temporaryMetadata.has('lastSuccessorTabId') && Tab.get(tab.$TST.temporaryMetadata.get('lastSuccessorTabId')); + if (!lastSuccessorTab) { + log(` ${dumpTab(tab)}'s successor is missing: it was already closed.`); + } + else { + log(` ${dumpTab(tab)} is under control: `, { + successorTabId: renewedTab.successorTabId, + lastSuccessorTabId: tab.$TST.temporaryMetadata.get('lastSuccessorTabId'), + }); + if (renewedTab.successorTabId != -1 && + renewedTab.successorTabId != tab.$TST.temporaryMetadata.get('lastSuccessorTabId')) { + log(` ${dumpTab(tab)}'s successor is modified by someone! Now it is out of control.`); + tab.$TST.temporaryMetadata.delete('lastSuccessorTabId'); + return; + } + } + tab.$TST.temporaryMetadata.delete('lastSuccessorTabId'); + if (configs.successorTabControlLevel == Constants.kSUCCESSOR_TAB_CONTROL_NEVER) + return; + let successor = null; + if (renewedTab.active) { + log('it is active, so reset successor'); + const excludeTabIdsSet = new Set(excludeTabIds); + const findSuccessor = (...candidates) => { + for (const candidate of candidates) { + if (!excludeTabIdsSet.has(candidate?.id) && + candidate) + return candidate; + } + return null; + }; + if (configs.successorTabControlLevel == Constants.kSUCCESSOR_TAB_CONTROL_IN_TREE) { + const closeParentBehavior = TreeBehavior.getParentTabOperationBehavior(tab, { + context: Constants.kPARENT_TAB_OPERATION_CONTEXT_CLOSE, + parent: tab.$TST.parent, + windowId: tab.windowId, + }); + const collapsedChildSuccessorAllowed = ( + closeParentBehavior != Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE && + closeParentBehavior != Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB + ); + const firstChild = collapsedChildSuccessorAllowed ? tab.$TST.firstChild : tab.$TST.firstVisibleChild; + const nextVisibleSibling = tab.$TST.nextVisibleSiblingTab; + const nearestVisiblePreceding = tab.$TST.nearestVisiblePrecedingTab; + successor = findSuccessor( + firstChild, + nextVisibleSibling, + nearestVisiblePreceding + ); + log(` possible successor: ${dumpTab(tab)}: `, successor, { + closeParentBehavior, + collapsedChildSuccessorAllowed, + parent: tab.$TST.parentId, + firstChild: firstChild?.id, + nextVisibleSibling: nextVisibleSibling?.id, + nearestVisiblePreceding: nearestVisiblePreceding?.id, + }); + if (successor && + successor.discarded && + configs.avoidDiscardedTabToBeActivatedIfPossible) { + log(` ${dumpTab(successor)} is discarded.`); + successor = findSuccessor( + tab.$TST.nearestLoadedSiblingTab, + tab.$TST.nearestLoadedTabInTree, + tab.$TST.nearestLoadedTab, + successor + ); + log(` => redirected successor is: ${dumpTab(successor)}`); + } + } + else { + successor = findSuccessor( + tab.$TST.nearestVisibleFollowingTab, + tab.$TST.nearestVisiblePrecedingTab + ); + log(` possible successor: ${dumpTab(tab)}`); + if (successor && + successor.discarded && + configs.avoidDiscardedTabToBeActivatedIfPossible) { + log(` ${dumpTab(successor)} is discarded.`); + successor = findSuccessor( + tab.$TST.nearestLoadedTab, + successor + ); + log(` => redirected successor is: ${dumpTab(successor)}`); + } + } + } + if (successor) { + log(` ${dumpTab(tab)} is under control: successor = ${successor.id}`); + setSuccessor(renewedTab.id, successor.id); + tab.$TST.temporaryMetadata.set('lastSuccessorTabId', successor.id); + } + else { + log(` ${dumpTab(tab)} is out of control.`, { + active: renewedTab.active, + }); + clearSuccessor(renewedTab.id); + } +} + +async function tryClearOwnerSuccessor(tab) { + if (!tab || + !tab.$TST.temporaryMetadata.get('lastSuccessorTabIdByOwner')) + return; + tab.$TST.temporaryMetadata.delete('lastSuccessorTabIdByOwner'); + const renewedTab = await browser.tabs.get(tab.id).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + if (!renewedTab || + renewedTab.successorTabId != tab.$TST.temporaryMetadata.get('lastSuccessorTabId')) + return; + log(`${dumpTab(tab)} is unprepared for "selectOwnerOnClose" behavior`); + tab.$TST.temporaryMetadata.delete('lastSuccessorTabId'); + clearSuccessor(tab.id); +} + +Tab.onActivated.addListener(async (newActiveTab, info = {}) => { + update(newActiveTab.id); + if (info.previousTabId) { + const oldActiveTab = Tab.get(info.previousTabId); + if (oldActiveTab) { + await tryClearOwnerSuccessor(oldActiveTab); + const lastRelatedTab = oldActiveTab.$TST.lastRelatedTab; + const newRelatedTabsCount = oldActiveTab.$TST.newRelatedTabsCount; + if (lastRelatedTab) { + log(`clear lastRelatedTabs for the window ${info.windowId} by tabs.onActivated on ${newActiveTab.id}`); + TabsStore.windows.get(info.windowId).clearLastRelatedTabs(); + if (lastRelatedTab.id != newActiveTab.id) { + log(`non last-related-tab is activated: cancel "back to owner" behavior for ${lastRelatedTab.id}`); + await tryClearOwnerSuccessor(lastRelatedTab); + } + } + if (newRelatedTabsCount > 1) { + log(`multiple related tabs were opened: cancel "back to owner" behavior for ${newActiveTab.id}`); + await tryClearOwnerSuccessor(newActiveTab); + } + } + update(info.previousTabId); + } +}); + +Tab.onCreating.addListener((tab, info = {}) => { + if (!info.activeTab) + return; + + const shouldControlSuccesor = ( + configs.successorTabControlLevel != Constants.kSUCCESSOR_TAB_CONTROL_NEVER && + configs.simulateSelectOwnerOnClose + ); + + log(`Tab.onCreating: should control succesor of ${tab.id}: `, shouldControlSuccesor); + if (shouldControlSuccesor) { + // don't use await here, to prevent that other onCreating handlers are treated async. + tryClearOwnerSuccessor(info.activeTab).then(() => { + const ownerTabId = tab.openerTabId || tab.active ? info.activeTab.id : null + if (!ownerTabId) + return; + + log(`${dumpTab(tab)} is prepared for "selectOwnerOnClose" behavior (successor=${ownerTabId})`); + setSuccessor(tab.id, ownerTabId); + tab.$TST.temporaryMetadata.set('lastSuccessorTabId', ownerTabId); + tab.$TST.temporaryMetadata.set('lastSuccessorTabIdByOwner', true); + + if (!tab.openerTabId) + return; + + const opener = Tab.get(tab.openerTabId); + const lastRelatedTab = opener?.$TST.lastRelatedTab; + log(`opener ${dumpTab(opener)}'s lastRelatedTab: ${dumpTab(lastRelatedTab)})`); + if (lastRelatedTab) { + log(' => clear successor'); + tryClearOwnerSuccessor(lastRelatedTab); + } + opener.$TST.lastRelatedTab = tab; + }); + } + else { + const opener = Tab.get(tab.openerTabId); + if (opener) + opener.$TST.lastRelatedTab = tab; + } +}); + +function updateActiveTab(windowId) { + const activeTab = Tab.getActiveTab(windowId); + if (activeTab) + update(activeTab.id); +} + +Tab.onCreated.addListener((tab, _info = {}) => { + updateActiveTab(tab.windowId); +}); + +Tab.onRemoving.addListener((tab, removeInfo = {}) => { + if (removeInfo.isWindowClosing) + return; + + const lastRelatedTab = tab.$TST.lastRelatedTab; + if (lastRelatedTab && + !lastRelatedTab.active) + tryClearOwnerSuccessor(lastRelatedTab); +}); + +Tab.onRemoved.addListener((tab, info = {}) => { + updateActiveTab(info.windowId); + + const win = TabsStore.windows.get(info.windowId); + if (!win) + return; + log(`clear lastRelatedTabs for ${info.windowId} by tabs.onRemoved`); + win.clearLastRelatedTabs(); +}); + +Tab.onMoved.addListener((tab, info = {}) => { + updateActiveTab(tab.windowId); + + if (!info.byInternalOperation) { + log(`clear lastRelatedTabs for ${tab.windowId} by tabs.onMoved`); + TabsStore.windows.get(info.windowId).clearLastRelatedTabs(); + } +}); + +Tab.onAttached.addListener((_tab, info = {}) => { + updateActiveTab(info.newWindowId); +}); + +Tab.onDetached.addListener((_tab, info = {}) => { + updateActiveTab(info.oldWindowId); + + const win = TabsStore.windows.get(info.oldWindowId); + if (win) { + log(`clear lastRelatedTabs for ${info.windowId} by tabs.onDetached`); + win.clearLastRelatedTabs(); + } +}); + +Tab.onUpdated.addListener((tab, changeInfo = {}) => { + if (!('discarded' in changeInfo)) + return; + updateActiveTab(tab.windowId); +}); + +Tab.onShown.addListener(tab => { + updateActiveTab(tab.windowId); +}); + +Tab.onHidden.addListener(tab => { + updateActiveTab(tab.windowId); +}); + +Tree.onAttached.addListener((child, { parent } = {}) => { + updateActiveTab(child.windowId); + + const lastRelatedTabId = parent.$TST.lastRelatedTabId; + if (lastRelatedTabId && + child.$TST.previousSiblingTab && + lastRelatedTabId == child.$TST.previousSiblingTab.id) + parent.$TST.lastRelatedTab = child; +}); + +Tree.onDetached.addListener((child, _info = {}) => { + updateActiveTab(child.windowId); +}); + +Tree.onSubtreeCollapsedStateChanging.addListener((tab, _info = {}) => { + updateActiveTab(tab.windowId); +}); + +SidebarConnection.onConnected.addListener((windowId, _openCount) => { + updateActiveTab(windowId); +}); + +SidebarConnection.onDisconnected.addListener((windowId, _openCount) => { + updateActiveTab(windowId); +}); + +// for automated test +browser.runtime.onMessage.addListener((message, _sender) => { + if (!message || !message.type) + return; + + switch (message.type) { + case Constants.kCOMMAND_WAIT_UNTIL_SUCCESSORS_UPDATED: + return Promise.all([...mInProgressUpdates]); + } +}); diff --git a/waterfox/browser/components/sidebar/background/sync-background.js b/waterfox/browser/components/sidebar/background/sync-background.js new file mode 100644 index 000000000000..0477b9b34954 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/sync-background.js @@ -0,0 +1,83 @@ +/* +# 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'; + +import { + configs, + notify, + wait, +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; +import * as ContextualIdentities from '/common/contextual-identities.js'; +import * as Sync from '/common/sync.js'; +import * as TabsStore from '/common/tabs-store.js'; + +import { Tab } from '/common/TreeItem.js'; + +import * as Tree from './tree.js'; + +Sync.onMessage.addListener(async message => { + const data = message.data; + if (data.type != Constants.kSYNC_DATA_TYPE_TABS || + !Array.isArray(data.tabs)) + return; + + const multiple = data.tabs.length > 1 ? '_multiple' : ''; + notify({ + title: browser.i18n.getMessage( + `receiveTabs_notification_title${multiple}`, + [Sync.getDeviceName(message.from)] + ), + message: browser.i18n.getMessage( + `receiveTabs_notification_message${multiple}`, + data.tabs.length > 1 ? + [data.tabs[0].url, data.tabs.length, data.tabs.length - 1] : + [data.tabs[0].url] + ), + timeout: configs.syncReceivedTabsNotificationTimeout + }); + + const windowId = TabsStore.getCurrentWindowId() || (await browser.windows.getCurrent()).id; + const win = TabsStore.windows.get(windowId); + const initialIndex = win.tabs.size; + win.toBeOpenedOrphanTabs += data.tabs.length; + let index = 0; + const tabs = []; + for (const tab of data.tabs) { + const createParams = { + windowId, + url: tab.url, + index: initialIndex + index, + active: index == 0 + }; + if (tab.cookieStoreId && + tab.cookieStoreId != 'firefox-default' && + ContextualIdentities.get(tab.cookieStoreId)) + createParams.cookieStoreId = tab.cookieStoreId; + let openedTab; + try { + openedTab = await browser.tabs.create(createParams); + } + catch(error) { + console.log(error); + } + if (!openedTab) + openedTab = await browser.tabs.create({ + ...createParams, + url: `about:blank?${tab.url}` + }); + tabs.push(openedTab); + index++; + } + + if (!Array.isArray(data.structure)) + return; + + await wait(100); // wait for all tabs are tracked + await Tree.applyTreeStructureToTabs(tabs.map(tab => Tab.get(tab.id)), data.structure, { + broadcast: true + }); +}); diff --git a/waterfox/browser/components/sidebar/background/tab-context-menu.js b/waterfox/browser/components/sidebar/background/tab-context-menu.js new file mode 100644 index 000000000000..cf8802667bb2 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/tab-context-menu.js @@ -0,0 +1,1860 @@ +/* +# 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'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; + +import { + log as internalLogger, + mapAndFilter, + configs, + sanitizeForHTMLText +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from '/common/constants.js'; +import * as ContextualIdentities from '/common/contextual-identities.js'; +import * as SidebarConnection from '/common/sidebar-connection.js'; +import * as Sync from '/common/sync.js'; +import * as TabsInternalOperation from '/common/tabs-internal-operation.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TreeBehavior from '/common/tree-behavior.js'; +import * as TSTAPI from '/common/tst-api.js'; + +import { Tab, TreeItem } from '/common/TreeItem.js'; + +import * as Commands from './commands.js'; +import * as NativeTabGroups from './native-tab-groups.js'; +import * as TabsOpen from './tabs-open.js'; + +function log(...args) { + internalLogger('background/tab-context-menu', ...args); +} + +export const onTSTItemClick = new EventListenerManager(); +export const onTSTTabContextMenuShown = new EventListenerManager(); +export const onTSTTabContextMenuHidden = new EventListenerManager(); +export const onTopLevelItemAdded = new EventListenerManager(); + +const EXTERNAL_TOP_LEVEL_ITEM_MATCHER = /^external-top-level-item:([^:]+):(.+)$/; +function getExternalTopLevelItemId(ownerId, itemId) { + return `external-top-level-item:${ownerId}:${itemId}`; +} + +const SAFE_MENU_PROPERTIES = [ + 'checked', + 'enabled', + 'icons', + 'parentId', + 'title', + 'type', + 'visible' +]; + +const mItemsById = { + 'context_newTab': { + title: browser.i18n.getMessage('tabContextMenu_newTab_label'), + titleTab: browser.i18n.getMessage('tabContextMenu_newTabNext_label'), + }, + 'context_newGroup': { + title: browser.i18n.getMessage('tabContextMenu_newGroup_label'), + titleMultiselected: browser.i18n.getMessage('tabContextMenu_newGroup_label_multiselected') + }, + 'context_addToGroup': { + title: browser.i18n.getMessage('tabContextMenu_addToGroup_label'), + titleMultiselected: browser.i18n.getMessage('tabContextMenu_addToGroup_label_multiselected') + }, + 'context_addToGroup_newGroup': { + parentId: 'context_addToGroup', + title: browser.i18n.getMessage('tabContextMenu_addToGroup_newGroup_label'), + }, + 'context_addToGroup_separator:afterNewGroup': { + parentId: 'context_addToGroup', + type: 'separator', + }, + 'context_removeFromGroup': { + title: browser.i18n.getMessage('tabContextMenu_removeFromGroup_label'), + titleMultiselected: browser.i18n.getMessage('tabContextMenu_removeFromGroup_label_multiselected') + }, + 'context_separator:afterNewTab': { + type: 'separator' + }, + 'context_reloadTab': { + title: browser.i18n.getMessage('tabContextMenu_reload_label'), + titleMultiselected: browser.i18n.getMessage('tabContextMenu_reload_label_multiselected') + }, + 'context_topLevel_reloadTree': { + title: browser.i18n.getMessage('context_reloadTree_label'), + titleMultiselected: browser.i18n.getMessage('context_reloadTree_label_multiselected') + }, + 'context_topLevel_reloadDescendants': { + title: browser.i18n.getMessage('context_reloadDescendants_label'), + titleMultiselected: browser.i18n.getMessage('context_reloadDescendants_label_multiselected') + }, + // This item won't be handled by the onClicked handler, so you may need to handle it with something experiments API. + 'context_unblockAutoplay': { + title: browser.i18n.getMessage('tabContextMenu_unblockAutoplay_label'), + titleMultiselected: browser.i18n.getMessage('tabContextMenu_unblockAutoplay_label_multiselected') + }, + // This item won't be handled by the onClicked handler, so you may need to handle it with something experiments API. + 'context_topLevel_unblockAutoplayTree': { + title: browser.i18n.getMessage('context_unblockAutoplayTree_label'), + titleMultiselected: browser.i18n.getMessage('context_unblockAutoplayTree_label_multiselected'), + }, + // This item won't be handled by the onClicked handler, so you may need to handle it with something experiments API. + 'context_topLevel_unblockAutoplayDescendants': { + title: browser.i18n.getMessage('context_unblockAutoplayDescendants_label'), + titleMultiselected: browser.i18n.getMessage('context_unblockAutoplayDescendants_label_multiselected'), + }, + 'context_toggleMuteTab': { + titleMute: browser.i18n.getMessage('tabContextMenu_mute_label'), + titleUnmute: browser.i18n.getMessage('tabContextMenu_unmute_label'), + titleMultiselectedMute: browser.i18n.getMessage('tabContextMenu_mute_label_multiselected'), + titleMultiselectedUnmute: browser.i18n.getMessage('tabContextMenu_unmute_label_multiselected') + }, + 'context_topLevel_toggleMuteTree': { + titleMuteTree: browser.i18n.getMessage('context_toggleMuteTree_label_mute'), + titleMultiselectedMuteTree: browser.i18n.getMessage('context_toggleMuteTree_label_multiselected_mute'), + titleUnmuteTree: browser.i18n.getMessage('context_toggleMuteTree_label_unmute'), + titleMultiselectedUnmuteTree: browser.i18n.getMessage('context_toggleMuteTree_label_multiselected_unmute') + }, + 'context_topLevel_toggleMuteDescendants': { + titleMuteDescendant: browser.i18n.getMessage('context_toggleMuteDescendants_label_mute'), + titleMultiselectedMuteDescendant: browser.i18n.getMessage('context_toggleMuteDescendants_label_multiselected_mute'), + titleUnmuteDescendant: browser.i18n.getMessage('context_toggleMuteDescendants_label_unmute'), + titleMultiselectedUnmuteDescendant: browser.i18n.getMessage('context_toggleMuteDescendants_label_multiselected_unmute') + }, + 'context_pinTab': { + title: browser.i18n.getMessage('tabContextMenu_pin_label'), + titleMultiselected: browser.i18n.getMessage('tabContextMenu_pin_label_multiselected') + }, + 'context_unpinTab': { + title: browser.i18n.getMessage('tabContextMenu_unpin_label'), + titleMultiselected: browser.i18n.getMessage('tabContextMenu_unpin_label_multiselected') + }, + 'context_topLevel_toggleSticky': { + titleStick: browser.i18n.getMessage('context_toggleSticky_label_stick'), + titleMultiselectedStick: browser.i18n.getMessage('context_toggleSticky_label_multiselected_stick'), + titleUnstick: browser.i18n.getMessage('context_toggleSticky_label_unstick'), + titleMultiselectedUnstick: browser.i18n.getMessage('context_toggleSticky_label_multiselected_unstick') + }, + 'context_unloadTab': { + title: browser.i18n.getMessage('tabContextMenu_unload_label'), + titleMultiselected: browser.i18n.getMessage('tabContextMenu_unload_label_multiselected'), + }, + 'context_duplicateTab': { + title: browser.i18n.getMessage('tabContextMenu_duplicate_label'), + titleMultiselected: browser.i18n.getMessage('tabContextMenu_duplicate_label_multiselected') + }, + 'context_separator:afterDuplicate': { + type: 'separator' + }, + 'context_bookmarkTab': { + title: browser.i18n.getMessage('tabContextMenu_bookmark_label'), + titleMultiselected: browser.i18n.getMessage('tabContextMenu_bookmark_label_multiselected') + }, + 'context_topLevel_bookmarkTree': { + title: browser.i18n.getMessage('context_bookmarkTree_label'), + titleMultiselected: browser.i18n.getMessage('context_bookmarkTree_label_multiselected') + }, + 'context_moveTab': { + title: browser.i18n.getMessage('tabContextMenu_moveTab_label'), + titleMultiselected: browser.i18n.getMessage('tabContextMenu_moveTab_label_multiselected') + }, + 'context_moveTabToStart': { + parentId: 'context_moveTab', + title: browser.i18n.getMessage('tabContextMenu_moveTabToStart_label') + }, + 'context_moveTabToEnd': { + parentId: 'context_moveTab', + title: browser.i18n.getMessage('tabContextMenu_moveTabToEnd_label') + }, + 'context_openTabInWindow': { + parentId: 'context_moveTab', + title: browser.i18n.getMessage('tabContextMenu_tearOff_label') + }, + 'context_shareTabURL': { + title: browser.i18n.getMessage('tabContextMenu_shareTabURL_label'), + }, + 'context_sendTabsToDevice': { + title: browser.i18n.getMessage('tabContextMenu_sendTabsToDevice_label'), + titleMultiselected: browser.i18n.getMessage('tabContextMenu_sendTabsToDevice_label_multiselected') + }, + 'context_topLevel_sendTreeToDevice': { + title: browser.i18n.getMessage('context_sendTreeToDevice_label'), + titleMultiselected: browser.i18n.getMessage('context_sendTreeToDevice_label_multiselected') + }, + 'context_reopenInContainer': { + title: browser.i18n.getMessage('tabContextMenu_reopenInContainer_label') + }, + 'context_selectAllTabs': { + title: browser.i18n.getMessage('tabContextMenu_selectAllTabs_label') + }, + 'context_separator:afterSelectAllTabs': { + type: 'separator' + }, + 'context_topLevel_collapseTree': { + title: browser.i18n.getMessage('context_collapseTree_label'), + titleMultiselected: browser.i18n.getMessage('context_collapseTree_label_multiselected') + }, + 'context_topLevel_collapseTreeRecursively': { + title: browser.i18n.getMessage('context_collapseTreeRecursively_label'), + titleMultiselected: browser.i18n.getMessage('context_collapseTreeRecursively_label_multiselected') + }, + 'context_topLevel_collapseAll': { + title: browser.i18n.getMessage('context_collapseAll_label') + }, + 'context_topLevel_expandTree': { + title: browser.i18n.getMessage('context_expandTree_label'), + titleMultiselected: browser.i18n.getMessage('context_expandTree_label_multiselected') + }, + 'context_topLevel_expandTreeRecursively': { + title: browser.i18n.getMessage('context_expandTreeRecursively_label'), + titleMultiselected: browser.i18n.getMessage('context_expandTreeRecursively_label_multiselected') + }, + 'context_topLevel_expandAll': { + title: browser.i18n.getMessage('context_expandAll_label') + }, + 'context_separator:afterCollapseExpand': { + type: 'separator' + }, + 'context_closeTab': { + title: browser.i18n.getMessage('tabContextMenu_close_label'), + titleMultiselected: browser.i18n.getMessage('tabContextMenu_close_label_multiselected') + }, + 'context_closeDuplicatedTabs': { + title: browser.i18n.getMessage('tabContextMenu_closeDuplicatedTabs_label') + }, + 'context_closeMultipleTabs': { + title: browser.i18n.getMessage('tabContextMenu_closeMultipleTabs_label') + }, + 'context_closeTabsToTheStart': { + parentId: 'context_closeMultipleTabs', + title: browser.i18n.getMessage('tabContextMenu_closeTabsToTop_label') + }, + 'context_closeTabsToTheEnd': { + parentId: 'context_closeMultipleTabs', + title: browser.i18n.getMessage('tabContextMenu_closeTabsToBottom_label') + }, + 'context_closeOtherTabs': { + parentId: 'context_closeMultipleTabs', + title: browser.i18n.getMessage('tabContextMenu_closeOther_label') + }, + 'context_topLevel_closeTree': { + title: browser.i18n.getMessage('context_closeTree_label'), + titleMultiselected: browser.i18n.getMessage('context_closeTree_label_multiselected') + }, + 'context_topLevel_closeDescendants': { + title: browser.i18n.getMessage('context_closeDescendants_label'), + titleMultiselected: browser.i18n.getMessage('context_closeDescendants_label_multiselected') + }, + 'context_topLevel_closeOthers': { + title: browser.i18n.getMessage('context_closeOthers_label'), + titleMultiselected: browser.i18n.getMessage('context_closeOthers_label_multiselected') + }, + 'context_undoCloseTab': { + title: browser.i18n.getMessage('tabContextMenu_undoClose_label'), + titleRegular: browser.i18n.getMessage('tabContextMenu_undoClose_label'), + titleMultipleTabsRestorable: browser.i18n.getMessage('tabContextMenu_undoClose_label_multiple') + }, + 'context_separator:afterClose': { + type: 'separator' + }, + + 'noContextTab:context_reloadTab': { + title: browser.i18n.getMessage('tabContextMenu_reloadSelected_label'), + titleMultiselected: browser.i18n.getMessage('tabContextMenu_reloadSelected_label_multiselected'), + }, + 'noContextTab:context_bookmarkSelected': { + title: browser.i18n.getMessage('tabContextMenu_bookmarkSelected_label'), + titleMultiselected: browser.i18n.getMessage('tabContextMenu_bookmarkSelected_label_multiselected'), + }, + 'noContextTab:context_selectAllTabs': { + title: browser.i18n.getMessage('tabContextMenu_selectAllTabs_label') + }, + 'noContextTab:context_undoCloseTab': { + title: browser.i18n.getMessage('tabContextMenu_undoClose_label') + }, + + 'lastSeparatorBeforeExtraItems': { + type: 'separator', + fakeMenu: true + } +}; + +const mExtraItems = new Map(); + +const SIDEBAR_URL_PATTERN = [`${Constants.kSHORTHAND_URIS.tabbar}*`]; + +let mInitialized = false; + +browser.runtime.onMessage.addListener(onMessage); +browser.menus.onShown.addListener(onShown); +browser.menus.onHidden.addListener(onHidden); +browser.menus.onClicked.addListener(onClick); +TSTAPI.onMessageExternal.addListener(onMessageExternal); + +function getItemPlacementSignature(item) { + if (item.placementSignature) + return item.placementSignature; + return item.placementSignature = JSON.stringify({ + parentId: item.parentId + }); +} +export async function init() { + mInitialized = true; + + window.addEventListener('unload', () => { + browser.runtime.onMessage.removeListener(onMessage); + TSTAPI.onMessageExternal.removeListener(onMessageExternal); + }, { once: true }); + + const itemIds = Object.keys(mItemsById); + for (const id of itemIds) { + const item = mItemsById[id]; + item.id = id; + item.lastTitle = item.title; + item.lastVisible = false; + item.lastEnabled = true; + if (item.type == 'separator') { + let beforeSeparator = true; + item.precedingItems = []; + item.followingItems = []; + for (const id of itemIds) { + const possibleSibling = mItemsById[id]; + if (getItemPlacementSignature(item) != getItemPlacementSignature(possibleSibling)) { + if (beforeSeparator) + continue; + else + break; + } + if (id == item.id) { + beforeSeparator = false; + continue; + } + if (beforeSeparator) { + if (possibleSibling.type == 'separator') { + item.previousSeparator = possibleSibling; + item.precedingItems = []; + } + else { + item.precedingItems.push(id); + } + } + else { + if (possibleSibling.type == 'separator') + break; + else + item.followingItems.push(id); + } + } + } + const info = { + id, + title: item.title, + type: item.type || 'normal', + contexts: ['tab'], + viewTypes: ['sidebar', 'tab', 'popup'], + visible: false, // hide all by default + documentUrlPatterns: SIDEBAR_URL_PATTERN + }; + if (item.parentId) + info.parentId = item.parentId; + if (!item.fakeMenu) + browser.menus.create(info); + onMessageExternal({ + type: TSTAPI.kCONTEXT_MENU_CREATE, + params: info + }, browser.runtime); + } + onTSTItemClick.addListener(onClick); + + await ContextualIdentities.init(); + updateContextualIdentities(); + ContextualIdentities.onUpdated.addListener(() => { + updateContextualIdentities(); + }); +} + + +// Workaround for https://github.com/piroor/treestyletab/issues/3423 +// Firefox does not provide any API to access to the sharing service of the platform. +// We need to provide it as experiments API or something way. +// This module is designed to work with a service which has features: +// * async listServices(tab) +// - Returns an array of sharing services on macOS. +// - Retruned array should have 0 or more items like: +// { name: "service name", +// title: "title for a menu item", +// image: "icon image (maybe data: URI)" } +// * share(tab, shareName = null) +// - Returns nothing. +// - Shares the specified tab with the specified service. +// The second argument is optional because it is required only on macOS. +// * openPreferences() +// - Returns nothing. +// - Opens preferences of sharing services on macOS. + +let mSharingService = null; + +export function registerSharingService(service) { + mSharingService = service; +} + + +let mMultipleTabsRestorable = false; +Tab.onChangeMultipleTabsRestorability.addListener(multipleTabsRestorable => { + mMultipleTabsRestorable = multipleTabsRestorable; +}); + +const mNativeTabGroupItems = new Set(); +function updateNativeTabGroups(contextTab) { + if (!contextTab || + !configs.tabGroupsEnabled) { + return; + } + + for (const item of mNativeTabGroupItems) { + const id = item.id; + if (id in mItemsById) + delete mItemsById[id]; + browser.menus.remove(id).catch(ApiTabs.createErrorSuppressor()); + onMessageExternal({ + type: TSTAPI.kCONTEXT_MENU_REMOVE, + params: id + }, browser.runtime); + } + mNativeTabGroupItems.clear(); + + updateItem('context_addToGroup_newGroup', { + visible: true, + }); + updateItem('context_addToGroup_separator:afterNewGroup', { + visible: true, + }); + + const defaultTitle = browser.i18n.getMessage('tabContextMenu_addToGroup_unnamed_label'); + const darkSuffix = window.matchMedia('(prefers-color-scheme: dark)').matches ? '-invert' : ''; + const groups = getEffectiveTabGroups(contextTab.windowId); + for (const group of groups) { + if (contextTab.groupId == group.id) { + continue; + } + const id = `context_addToGroup:group:${group.id}`; + const item = { + id, + parentId: 'context_addToGroup', + title: group.title || defaultTitle, + icons: { 16: `/resources/icons/tab-group-chicklet.svg#${group.color}${darkSuffix}` }, + contexts: ['tab'], + viewTypes: ['sidebar', 'tab', 'popup'], + documentUrlPatterns: SIDEBAR_URL_PATTERN, + }; + browser.menus.create(item); + onMessageExternal({ + type: TSTAPI.kCONTEXT_MENU_CREATE, + params: item + }, browser.runtime); + mNativeTabGroupItems.add(item); + mItemsById[item.id] = item; + item.lastVisible = true; + item.lastEnabled = true; + } +} + +function getEffectiveTabGroups(windowId) { + return TreeItem.sort( + [...TabsStore.windows.get(windowId).tabGroups.values()] + .filter(group => !!group.$TST.firstMember) + ); +} + +const mContextualIdentityItems = new Set(); +function updateContextualIdentities() { + for (const item of mContextualIdentityItems) { + const id = item.id; + if (id in mItemsById) + delete mItemsById[id]; + browser.menus.remove(id).catch(ApiTabs.createErrorSuppressor()); + onMessageExternal({ + type: TSTAPI.kCONTEXT_MENU_REMOVE, + params: id + }, browser.runtime); + } + mContextualIdentityItems.clear(); + + const defaultItem = { + parentId: 'context_reopenInContainer', + id: 'context_reopenInContainer:firefox-default', + title: browser.i18n.getMessage('tabContextMenu_reopenInContainer_noContainer_label'), + contexts: ['tab'], + viewTypes: ['sidebar', 'tab', 'popup'], + documentUrlPatterns: SIDEBAR_URL_PATTERN + }; + browser.menus.create(defaultItem); + onMessageExternal({ + type: TSTAPI.kCONTEXT_MENU_CREATE, + params: defaultItem + }, browser.runtime); + mContextualIdentityItems.add(defaultItem); + + const defaultSeparator = { + parentId: 'context_reopenInContainer', + id: 'context_reopenInContainer_separator', + type: 'separator', + contexts: ['tab'], + viewTypes: ['sidebar', 'tab', 'popup'], + documentUrlPatterns: SIDEBAR_URL_PATTERN + }; + browser.menus.create(defaultSeparator); + onMessageExternal({ + type: TSTAPI.kCONTEXT_MENU_CREATE, + params: defaultSeparator + }, browser.runtime); + mContextualIdentityItems.add(defaultSeparator); + + ContextualIdentities.forEach(identity => { + const id = `context_reopenInContainer:${identity.cookieStoreId}`; + const item = { + parentId: 'context_reopenInContainer', + id: id, + title: identity.name.replace(/^([a-z0-9])/i, '&$1'), + contexts: ['tab'], + viewTypes: ['sidebar', 'tab', 'popup'], + documentUrlPatterns: SIDEBAR_URL_PATTERN + }; + if (identity.iconUrl) + item.icons = { 16: identity.iconUrl }; + browser.menus.create(item); + onMessageExternal({ + type: TSTAPI.kCONTEXT_MENU_CREATE, + params: item + }, browser.runtime); + mContextualIdentityItems.add(item); + mItemsById[item.id] = item; + item.lastVisible = true; + item.lastEnabled = true; + }); +} + +const mLastDevicesSignature = new Map(); +const mSendToDeviceItems = new Map(); +export async function updateSendToDeviceItems(parentId, { manage } = {}) { + const devices = await Sync.getOtherDevices(); + const signature = JSON.stringify(devices.map(device => ({ id: device.id, name: device.name }))); + if (signature == mLastDevicesSignature.get(parentId)) + return false; + + mLastDevicesSignature.set(parentId, signature); + + const items = mSendToDeviceItems.get(parentId) || new Set(); + for (const item of items) { + const id = item.id; + browser.menus.remove(id).catch(ApiTabs.createErrorSuppressor()); + onMessageExternal({ + type: TSTAPI.kCONTEXT_MENU_REMOVE, + params: id + }, browser.runtime); + } + items.clear(); + + const baseParams = { + parentId, + contexts: ['tab'], + viewTypes: ['sidebar', 'tab', 'popup'], + documentUrlPatterns: SIDEBAR_URL_PATTERN + }; + + if (devices.length > 0) { + for (const device of devices) { + const item = { + ...baseParams, + type: 'normal', + id: `${parentId}:device:${device.id}`, + title: device.name + }; + if (device.icon) + item.icons = { + '16': `/resources/icons/${sanitizeForHTMLText(device.icon)}.svg` + }; + browser.menus.create(item); + onMessageExternal({ + type: TSTAPI.kCONTEXT_MENU_CREATE, + params: item + }, browser.runtime); + items.add(item); + } + + const separator = { + ...baseParams, + type: 'separator', + id: `${parentId}:separator` + }; + browser.menus.create(separator); + onMessageExternal({ + type: TSTAPI.kCONTEXT_MENU_CREATE, + params: separator + }, browser.runtime); + items.add(separator); + + const sendToAllItem = { + ...baseParams, + type: 'normal', + id: `${parentId}:all`, + title: browser.i18n.getMessage('tabContextMenu_sendTabsToAllDevices_label') + }; + browser.menus.create(sendToAllItem); + onMessageExternal({ + type: TSTAPI.kCONTEXT_MENU_CREATE, + params: sendToAllItem + }, browser.runtime); + items.add(sendToAllItem); + } + + if (manage) { + const manageItem = { + ...baseParams, + type: 'normal', + id: `${parentId}:manage`, + title: browser.i18n.getMessage('tabContextMenu_manageSyncDevices_label') + }; + browser.menus.create(manageItem); + onMessageExternal({ + type: TSTAPI.kCONTEXT_MENU_CREATE, + params: manageItem + }, browser.runtime); + items.add(manageItem); + } + + mSendToDeviceItems.set(parentId, items); + return true; +} + +const mLastSharingServicesSignature = new Map(); +const mShareItems = new Map(); +async function updateSharingServiceItems(parentId, contextTab) { + if (!mSharingService || + !contextTab) + return false; + + const services = await mSharingService.listServices(contextTab); + const signature = JSON.stringify(services); + if (signature == mLastSharingServicesSignature.get(parentId)) + return false; + + mLastSharingServicesSignature.set(parentId, signature); + + const items = mShareItems.get(parentId) || new Set(); + for (const item of items) { + const id = item.id; + browser.menus.remove(id).catch(ApiTabs.createErrorSuppressor()); + onMessageExternal({ + type: TSTAPI.kCONTEXT_MENU_REMOVE, + params: id + }, browser.runtime); + } + items.clear(); + + const baseParams = { + parentId, + contexts: ['tab'], + viewTypes: ['sidebar', 'tab', 'popup'], + documentUrlPatterns: SIDEBAR_URL_PATTERN + }; + + if (services.length > 0) { + for (const service of services) { + const item = { + ...baseParams, + type: 'normal', + id: `${parentId}:service:${service.name}`, + title: service.title, + }; + if (service.image) + item.icons = { + '16': service.image, + }; + browser.menus.create(item); + onMessageExternal({ + type: TSTAPI.kCONTEXT_MENU_CREATE, + params: item, + }, browser.runtime); + items.add(item); + } + + const separator = { + ...baseParams, + type: 'separator', + id: `${parentId}:separator`, + }; + browser.menus.create(separator); + onMessageExternal({ + type: TSTAPI.kCONTEXT_MENU_CREATE, + params: separator, + }, browser.runtime); + items.add(separator); + + const moreItem = { + ...baseParams, + type: 'normal', + id: `${parentId}:more`, + title: browser.i18n.getMessage('tabContextMenu_shareTabURL_more_label'), + icons: { + '16': '/resources/icons/more-horiz-16.svg', + }, + }; + browser.menus.create(moreItem); + onMessageExternal({ + type: TSTAPI.kCONTEXT_MENU_CREATE, + params: moreItem, + }, browser.runtime); + items.add(moreItem); + } + + mShareItems.set(parentId, items); + return true; +} + + +function updateItem(id, state = {}) { + let modified = false; + const item = mItemsById[id]; + if (!item) { + return false; + } + const updateInfo = { + visible: 'visible' in state ? !!state.visible : true, + enabled: 'enabled' in state ? !!state.enabled : true + }; + if ('checked' in state) + updateInfo.checked = state.checked; + const title = String( + (state.tab && state.titleTab) || + state.title || + (state.multiselected && item.titleMultiselected) || + item.title + ).replace(/%S/g, state.count || 0); + if (title) { + updateInfo.title = title; + modified = title != item.lastTitle; + item.lastTitle = updateInfo.title; + } + if (!modified) + modified = updateInfo.visible != item.lastVisible || + updateInfo.enabled != item.lastEnabled; + item.lastVisible = updateInfo.visible; + item.lastEnabled = updateInfo.enabled; + if (!item.fakeMenu) { + browser.menus.update(id, updateInfo).catch(ApiTabs.createErrorSuppressor()); + } + onMessageExternal({ + type: TSTAPI.kCONTEXT_MENU_UPDATE, + params: [id, updateInfo] + }, browser.runtime); + return modified; +} + +function updateSeparator(id, options = {}) { + const item = mItemsById[id]; + const visible = ( + (options.hasVisiblePreceding || + hasVisiblePrecedingItem(item)) && + (options.hasVisibleFollowing || + item.followingItems.some(id => mItemsById[id].type != 'separator' && mItemsById[id].lastVisible)) + ); + return updateItem(id, { visible }); +} +function hasVisiblePrecedingItem(separator) { + return ( + separator.precedingItems.some(id => mItemsById[id].type != 'separator' && mItemsById[id].lastVisible) || + (separator.previousSeparator && + !separator.previousSeparator.lastVisible && + hasVisiblePrecedingItem(separator.previousSeparator)) + ); +} + +let mOverriddenContext = null; +let mLastContextTabId = null; + +async function onShown(info, contextTab) { + if (!mInitialized) + return; + + const contextTabId = contextTab?.id; + mLastContextTabId = contextTabId; + try { + contextTab = Tab.get(contextTabId); + + const windowId = contextTab ? contextTab.windowId : (await browser.windows.getLastFocused({}).catch(ApiTabs.createErrorHandler())).id; + if (mLastContextTabId != contextTabId) + return; // Skip further operations if the menu was already reopened on a different context tab. + const previousTab = contextTab?.$TST.previousTab; + const previousSiblingTab = contextTab?.$TST.previousSiblingTab; + const nextTab = contextTab?.$TST.nextTab; + const nextSiblingTab = contextTab?.$TST.nextSiblingTab; + const hasDuplicatedTabs = Tab.hasDuplicatedTabs(windowId); + const hasMultipleTabs = Tab.hasMultipleTabs(windowId); + const hasMultipleNormalTabs = Tab.hasMultipleTabs(windowId, { normal: true }); + const multiselected = contextTab?.$TST.multiselected; + const contextTabs = multiselected ? + Tab.getSelectedTabs(windowId) : + contextTab ? + [contextTab] : + []; + const hasChild = contextTab && contextTabs.some(tab => tab.$TST.hasChild); + const { hasUnmutedTab, hasUnmutedDescendant } = Commands.getUnmutedState(contextTabs); + const { hasAutoplayBlockedTab, hasAutoplayBlockedDescendant } = Commands.getAutoplayBlockedState(contextTabs); + const hasChoosableNativeTabGroup = contextTab && getEffectiveTabGroups(windowId).filter(group => group.id != contextTab.groupId).length > 0; + + if (mOverriddenContext) + return onOverriddenMenuShown(info, contextTab, windowId); + + let modifiedItemsCount = cleanupOverriddenMenu(); + + // ESLint reports "short circuit" error for following codes. + // https://eslint.org/docs/rules/no-unused-expressions#allowshortcircuit + // To allow those usages, I disable the rule temporarily. + /* eslint-disable no-unused-expressions */ + + const emulate = configs.emulateDefaultContextMenu; + + updateItem('context_newTab', { + visible: emulate, + tab: !!contextTab, + }) && modifiedItemsCount++; + + updateItem('context_newGroup', { + visible: emulate && configs.tabGroupsEnabled && !!contextTab && !hasChoosableNativeTabGroup, + multiselected + }) && modifiedItemsCount++; + updateItem('context_addToGroup', { + visible: emulate && configs.tabGroupsEnabled && !!contextTab && hasChoosableNativeTabGroup, + multiselected + }) && modifiedItemsCount++; + updateItem('context_removeFromGroup', { + visible: emulate && configs.tabGroupsEnabled && !!contextTab && contextTab.groupId != -1, + multiselected + }) && modifiedItemsCount++; + + updateItem('context_separator:afterNewTab', { + visible: emulate, + }) && modifiedItemsCount++; + + updateItem('context_reloadTab', { + visible: emulate && !!contextTab, + multiselected + }) && modifiedItemsCount++; + updateItem('context_topLevel_reloadTree', { + visible: emulate && !!contextTab && configs.context_topLevel_reloadTree, + multiselected + }) && modifiedItemsCount++; + updateItem('context_topLevel_reloadDescendants', { + visible: emulate && !!contextTab && configs.context_topLevel_reloadDescendants, + enabled: hasChild, + multiselected + }) && modifiedItemsCount++; + updateItem('context_unblockAutoplay', { + visible: emulate && contextTab?.$TST.autoplayBlocked, + multiselected, + title: contextTab && Commands.getMenuItemTitle(mItemsById.context_unblockAutoplay, { + multiselected, + }), + }) && modifiedItemsCount++; + updateItem('context_topLevel_unblockAutoplayTree', { + visible: emulate && hasChild && hasAutoplayBlockedTab && configs.context_topLevel_unblockAutoplayTree, + multiselected, + title: contextTab && Commands.getMenuItemTitle(mItemsById.context_topLevel_unblockAutoplayTree, { + multiselected, + }), + }) && modifiedItemsCount++; + updateItem('context_topLevel_unblockAutoplayDescendants', { + visible: emulate && hasChild && hasAutoplayBlockedDescendant && configs.context_topLevel_unblockAutoplayDescendants, + multiselected, + title: contextTab && Commands.getMenuItemTitle(mItemsById.context_topLevel_unblockAutoplayDescendants, { + multiselected, + }), + }) && modifiedItemsCount++; + updateItem('context_toggleMuteTab', { + visible: emulate && !!contextTab, + multiselected, + title: contextTab && Commands.getMenuItemTitle(mItemsById.context_toggleMuteTab, { + multiselected, + unmuted: (!contextTab.mutedInfo || !contextTab.mutedInfo.muted), + }), + }) && modifiedItemsCount++; + updateItem('context_topLevel_toggleMuteTree', { + visible: emulate && !!contextTab && configs.context_topLevel_toggleMuteTree, + enabled: hasChild, + multiselected, + title: Commands.getMenuItemTitle(mItemsById.context_topLevel_toggleMuteTree, { multiselected, hasUnmutedTab, hasUnmutedDescendant }), + hasUnmutedTab, + hasUnmutedDescendant, + }) && modifiedItemsCount++; + updateItem('context_topLevel_toggleMuteDescendants', { + visible: emulate && !!contextTab && configs.context_topLevel_toggleMuteDescendants, + enabled: hasChild, + multiselected, + title: Commands.getMenuItemTitle(mItemsById.context_topLevel_toggleMuteDescendants, { multiselected, hasUnmutedTab, hasUnmutedDescendant }), + hasUnmutedTab, + hasUnmutedDescendant, + }) && modifiedItemsCount++; + updateItem('context_pinTab', { + visible: emulate && !!contextTab && !contextTab.pinned, + multiselected + }) && modifiedItemsCount++; + updateItem('context_unpinTab', { + visible: emulate && !!contextTab?.pinned, + multiselected + }) && modifiedItemsCount++; + updateItem('context_topLevel_toggleSticky', { + visible: emulate && !!contextTab, + enabled: contextTab && !contextTab.pinned, + multiselected, + title: contextTab && Commands.getMenuItemTitle(mItemsById.context_topLevel_toggleSticky, { + multiselected, + sticky: contextTab?.$TST.sticky, + }), + }) && modifiedItemsCount++; + const unloadableCount = Commands.filterUnloadableTabs(contextTabs).length; + updateItem('context_unloadTab', { + visible: emulate && unloadableCount > 0, + multiselected: unloadableCount > 1, + count: unloadableCount, + }) && modifiedItemsCount++; + updateItem('context_duplicateTab', { + visible: emulate && !!contextTab, + multiselected + }) && modifiedItemsCount++; + + updateItem('context_bookmarkTab', { + visible: emulate && !!contextTab, + multiselected: multiselected || !contextTab + }) && modifiedItemsCount++; + updateItem('context_topLevel_bookmarkTree', { + visible: emulate && !!contextTab && configs.context_topLevel_bookmarkTree, + multiselected + }) && modifiedItemsCount++; + + updateItem('context_moveTab', { + visible: emulate && !!contextTab, + enabled: contextTab && hasMultipleTabs, + multiselected + }) && modifiedItemsCount++; + updateItem('context_moveTabToStart', { + enabled: emulate && !!contextTab && hasMultipleTabs && (previousSiblingTab || previousTab) && ((previousSiblingTab || previousTab).pinned == contextTab.pinned), + multiselected + }) && modifiedItemsCount++; + updateItem('context_moveTabToEnd', { + enabled: emulate && !!contextTab && hasMultipleTabs && (nextSiblingTab || nextTab) && ((nextSiblingTab || nextTab).pinned == contextTab.pinned), + multiselected + }) && modifiedItemsCount++; + updateItem('context_openTabInWindow', { + enabled: emulate && !!contextTab && hasMultipleTabs, + multiselected + }) && modifiedItemsCount++; + + // Not implemented yet as a built-in. See also: https://github.com/piroor/treestyletab/issues/3423 + updateItem('context_shareTabURL', { + visible: emulate && !!contextTab && mSharingService && Sync.isSendableTab(contextTab), + }) && modifiedItemsCount++; + + updateItem('context_sendTabsToDevice', { + visible: emulate && !!contextTab && contextTabs.filter(Sync.isSendableTab).length > 0, + multiselected, + count: contextTabs.length + }) && modifiedItemsCount++; + updateItem('context_topLevel_sendTreeToDevice', { + visible: emulate && !!contextTab && contextTabs.filter(Sync.isSendableTab).length > 0 && configs.context_topLevel_sendTreeToDevice && hasChild, + enabled: hasChild, + multiselected + }) && modifiedItemsCount++; + + let showContextualIdentities = false; + if (contextTab && !contextTab.incognito) { + for (const item of mContextualIdentityItems.values()) { + const id = item.id; + let visible; + if (!emulate) + visible = false; + else if (id == 'context_reopenInContainer_separator') + visible = !!contextTab && contextTab.cookieStoreId != 'firefox-default'; + else + visible = !!contextTab && id != `context_reopenInContainer:${contextTab.cookieStoreId}`; + updateItem(id, { visible }) && modifiedItemsCount++; + if (visible) + showContextualIdentities = true; + } + } + updateItem('context_reopenInContainer', { + visible: emulate && !!contextTab && showContextualIdentities && !contextTab.incognito, + enabled: contextTabs.every(tab => TabsOpen.isOpenable(tab.url)), + multiselected + }) && modifiedItemsCount++; + + updateItem('context_selectAllTabs', { + visible: emulate && !!contextTab, + enabled: contextTab && Tab.getSelectedTabs(windowId).length != Tab.getVisibleTabs(windowId).length, + multiselected + }) && modifiedItemsCount++; + + updateItem('context_topLevel_collapseTree', { + visible: emulate && !!contextTab && configs.context_topLevel_collapseTree, + enabled: hasChild, + multiselected + }) && modifiedItemsCount++; + updateItem('context_topLevel_collapseTreeRecursively', { + visible: emulate && !!contextTab && configs.context_topLevel_collapseTreeRecursively, + enabled: hasChild, + multiselected + }) && modifiedItemsCount++; + updateItem('context_topLevel_collapseAll', { + visible: emulate && !multiselected && !!contextTab && configs.context_topLevel_collapseAll + }) && modifiedItemsCount++; + updateItem('context_topLevel_expandTree', { + visible: emulate && !!contextTab && configs.context_topLevel_expandTree, + enabled: hasChild, + multiselected + }) && modifiedItemsCount++; + updateItem('context_topLevel_expandTreeRecursively', { + visible: emulate && !!contextTab && configs.context_topLevel_expandTreeRecursively, + enabled: hasChild, + multiselected + }) && modifiedItemsCount++; + updateItem('context_topLevel_expandAll', { + visible: emulate && !multiselected && !!contextTab && configs.context_topLevel_expandAll + }) && modifiedItemsCount++; + + updateItem('context_closeTab', { + visible: emulate && !!contextTab, + multiselected, + count: contextTabs.length + }) && modifiedItemsCount++; + + updateItem('context_closeDuplicatedTabs', { + visible: emulate && !!contextTab, + enabled: hasDuplicatedTabs && !multiselected, + multiselected + }) && modifiedItemsCount++; + updateItem('context_closeMultipleTabs', { + visible: emulate && !!contextTab, + enabled: hasMultipleNormalTabs, + multiselected + }) && modifiedItemsCount++; + updateItem('context_closeTabsToTheStart', { + visible: emulate && !!contextTab, + enabled: nextTab, + multiselected + }) && modifiedItemsCount++; + updateItem('context_closeTabsToTheEnd', { + visible: emulate && !!contextTab, + enabled: nextTab, + multiselected + }) && modifiedItemsCount++; + updateItem('context_closeOtherTabs', { + visible: emulate && !!contextTab, + enabled: hasMultipleNormalTabs, + multiselected + }) && modifiedItemsCount++; + + updateItem('context_topLevel_closeTree', { + visible: emulate && !!contextTab && configs.context_topLevel_closeTree, + multiselected + }) && modifiedItemsCount++; + updateItem('context_topLevel_closeDescendants', { + visible: emulate && !!contextTab && configs.context_topLevel_closeDescendants, + enabled: hasChild, + multiselected + }) && modifiedItemsCount++; + updateItem('context_topLevel_closeOthers', { + visible: emulate && !!contextTab && configs.context_topLevel_closeOthers, + multiselected + }) && modifiedItemsCount++; + + const undoCloseTabLabel = mItemsById.context_undoCloseTab[configs.undoMultipleTabsClose && mMultipleTabsRestorable ? 'titleMultipleTabsRestorable' : 'titleRegular']; + updateItem('context_undoCloseTab', { + title: undoCloseTabLabel, + visible: emulate && !!contextTab, + multiselected + }) && modifiedItemsCount++; + + updateItem('noContextTab:context_reloadTab', { + visible: emulate && !contextTab + }) && modifiedItemsCount++; + updateItem('noContextTab:context_bookmarkSelected', { + visible: emulate && !contextTab + }) && modifiedItemsCount++; + updateItem('noContextTab:context_selectAllTabs', { + visible: emulate && !contextTab, + enabled: !contextTab && Tab.getSelectedTabs(windowId).length != Tab.getVisibleTabs(windowId).length + }) && modifiedItemsCount++; + updateItem('noContextTab:context_undoCloseTab', { + title: undoCloseTabLabel, + visible: emulate && !contextTab + }) && modifiedItemsCount++; + + updateSeparator('context_separator:afterDuplicate') && modifiedItemsCount++; + updateSeparator('context_separator:afterSelectAllTabs') && modifiedItemsCount++; + updateSeparator('context_separator:afterCollapseExpand') && modifiedItemsCount++; + updateSeparator('context_separator:afterClose') && modifiedItemsCount++; + + const flattenExtraItems = Array.from(mExtraItems.values()).flat(); + + updateSeparator('lastSeparatorBeforeExtraItems', { + hasVisibleFollowing: contextTab && flattenExtraItems.some(item => !item.parentId && item.visible !== false) + }) && modifiedItemsCount++; + + // these items should be updated at the last to reduce flicking of showing context menu + await Promise.all([ + updateNativeTabGroups(contextTab), + updateSendToDeviceItems('context_sendTabsToDevice', { manage: true }), + mItemsById.context_topLevel_sendTreeToDevice.lastVisible && updateSendToDeviceItems('context_topLevel_sendTreeToDevice'), + modifiedItemsCount > 0 && browser.menus.refresh().catch(ApiTabs.createErrorSuppressor()).then(_ => false), + updateSharingServiceItems('context_shareTabURL', contextTab), + ]).then(results => { + modifiedItemsCount = 0; + for (const modified of results) { + if (modified) + modifiedItemsCount++; + } + }); + if (mLastContextTabId != contextTabId) + return; // Skip further operations if the menu was already reopened on a different context tab. + + /* eslint-enable no-unused-expressions */ + + if (modifiedItemsCount > 0) + browser.menus.refresh().catch(ApiTabs.createErrorSuppressor()); + } + catch(error) { + console.error(error); + } +} + +let mLastOverriddenContextOwner = null; + +function onOverriddenMenuShown(info, contextTab, windowId) { + if (!mLastOverriddenContextOwner) { + for (const itemId of Object.keys(mItemsById)) { + if (!mItemsById[itemId].lastVisible) + continue; + mItemsById[itemId].lastVisible = false; + browser.menus.update(itemId, { visible: false }); + } + mLastOverriddenContextOwner = mOverriddenContext.owner; + } + + for (const item of (mExtraItems.get(mLastOverriddenContextOwner) || [])) { + if (item.$topLevel && + item.lastVisible) { + browser.menus.update( + getExternalTopLevelItemId(mOverriddenContext.owner, item.id), + { visible: true } + ); + } + } + + const cache = {}; + const message = { + type: TSTAPI.kCONTEXT_MENU_SHOWN, + info: { + bookmarkId: info.bookmarkId || null, + button: info.button, + checked: info.checked, + contexts: contextTab ? ['tab'] : info.bookmarkId ? ['bookmark'] : [], + editable: false, + frameId: null, + frameUrl: null, + linkText: null, + linkUrl: null, + mediaType: null, + menuIds: [], + menuItemId: null, + modifiers: [], + pageUrl: null, + parentMenuItemId: null, + selectionText: null, + srcUrl: null, + targetElementId: null, + viewType: 'sidebar', + wasChecked: false + }, + tab: contextTab, + windowId + } + TSTAPI.broadcastMessage(message, { + targets: [mOverriddenContext.owner], + tabProperties: ['tab'], + isContextTab: true, + cache, + }); + TSTAPI.broadcastMessage({ + ...message, + type: TSTAPI.kFAKE_CONTEXT_MENU_SHOWN + }, { + targets: [mOverriddenContext.owner], + tabProperties: ['tab'] + }); + + reserveRefresh(); +} + +function cleanupOverriddenMenu() { + if (!mLastOverriddenContextOwner) + return 0; + + let modifiedItemsCount = 0; + + const owner = mLastOverriddenContextOwner; + mLastOverriddenContextOwner = null; + + for (const itemId of Object.keys(mItemsById)) { + if (!mItemsById[itemId].lastVisible) + continue; + mItemsById[itemId].lastVisible = true; + browser.menus.update(itemId, { visible: true }); + modifiedItemsCount++; + } + + for (const item of (mExtraItems.get(owner) || [])) { + if (item.$topLevel && + item.lastVisible) { + browser.menus.update( + getExternalTopLevelItemId(owner, item.id), + { visible: false } + ); + modifiedItemsCount++; + } + } + + return modifiedItemsCount; +} + +function onHidden() { + if (!mInitialized) + return; + + const owner = mOverriddenContext?.owner; + const windowId = mOverriddenContext?.windowId; + if (mLastOverriddenContextOwner && + owner == mLastOverriddenContextOwner) { + mOverriddenContext = null; + TSTAPI.broadcastMessage({ + type: TSTAPI.kCONTEXT_MENU_HIDDEN, + windowId + }, { + targets: [owner] + }); + TSTAPI.broadcastMessage({ + type: TSTAPI.kFAKE_CONTEXT_MENU_HIDDEN, + windowId + }, { + targets: [owner] + }); + } +} + +async function onClick(info, contextTab) { + if (!mInitialized) + return; + + contextTab = Tab.get(contextTab?.id); + const win = await browser.windows.getLastFocused({ populate: true }).catch(ApiTabs.createErrorHandler()); + const windowId = contextTab?.windowId || win.id; + const activeTab = TabsStore.activeTabInWindow.get(windowId); + + let multiselectedTabs = Tab.getSelectedTabs(windowId); + const isMultiselected = contextTab ? contextTab.$TST.multiselected : multiselectedTabs.length > 1; + if (!isMultiselected) + multiselectedTabs = null; + + switch (info.menuItemId.replace(/^noContextTab:/, '')) { + case 'context_newTab': { + const behavior = info.button == 1 ? + configs.autoAttachOnNewTabButtonMiddleClick : + (info.modifiers && (info.modifiers.includes('Ctrl') || info.modifiers.includes('Command'))) ? + configs.autoAttachOnNewTabButtonAccelClick : + contextTab ? + configs.autoAttachOnContextNewTabCommand : + configs.autoAttachOnNewTabCommand; + Commands.openNewTabAs({ + baseTab: contextTab || activeTab, + as: behavior, + }); + }; break; + + case 'context_newGroup': + case 'context_addToGroup_newGroup': + NativeTabGroups.addTabsToGroup(multiselectedTabs || [contextTab]).then(({ groupId, created }) => { + if (!created) { + return; + } + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_SHOW_NATIVE_TAB_GROUP_MENU_PANEL, + windowId, + groupId, + }); + }); + break; + + case 'context_removeFromGroup': + NativeTabGroups.removeTabsFromGroup(multiselectedTabs || [contextTab]); + break; + + case 'context_reloadTab': + if (multiselectedTabs) { + for (const tab of multiselectedTabs) { + browser.tabs.reload(tab.id) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } + } + else { + const tab = contextTab || activeTab; + browser.tabs.reload(tab.id) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } + break; + case 'context_toggleMuteTab': { + const tab = contextTab || activeTab; + const unmuted = !tab.mutedInfo || !tab.mutedInfo.muted; + if (multiselectedTabs) { + for (const tab of multiselectedTabs) { + browser.tabs.update(tab.id, { muted: unmuted }) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } + } + else { + browser.tabs.update(contextTab.id, { muted: unmuted }) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } + }; break; + case 'context_pinTab': + if (multiselectedTabs) { + for (const tab of multiselectedTabs) { + browser.tabs.update(tab.id, { pinned: true }) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } + } + else { + browser.tabs.update(contextTab.id, { pinned: true }) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } + break; + case 'context_unpinTab': + if (multiselectedTabs) { + for (const tab of multiselectedTabs) { + browser.tabs.update(tab.id, { pinned: false }) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } + } + else { + browser.tabs.update(contextTab.id, { pinned: false }) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } + break; + case 'context_toggleSticky': + Commands.toggleSticky(multiselectedTabs, !(contextTab || activeTab).$TST.sticky); + break; + case 'context_unloadTab': + Commands.unloadTabs(multiselectedTabs || [contextTab]); + break; + case 'context_duplicateTab': + Commands.duplicateTab(contextTab, { + destinationWindowId: windowId + }); + break; + case 'context_moveTabToStart': + Commands.moveTabToStart(contextTab); + break; + case 'context_moveTabToEnd': + Commands.moveTabToEnd(contextTab); + break; + case 'context_openTabInWindow': + Commands.openTabInWindow(contextTab, { withTree: true }); + break; + case 'context_shareTabURL': + if (mSharingService) + mSharingService.share(contextTab); + break; + case 'context_shareTabURL:more': + if (mSharingService) + mSharingService.openPreferences(); + break; + case 'context_sendTabsToDevice:all': + Sync.sendTabsToAllDevices(multiselectedTabs || [contextTab]); + break; + case 'context_sendTabsToDevice:manage': + Sync.manageDevices(windowId); + break; + case 'context_topLevel_sendTreeToDevice:all': + Sync.sendTabsToAllDevices(multiselectedTabs || [contextTab], { recursively: true }); + break; + case 'context_selectAllTabs': { + const tabs = await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler()); + TabsInternalOperation.highlightTabs( + [activeTab].concat(mapAndFilter(tabs, tab => !tab.active ? tab : undefined)) + ).catch(ApiTabs.createErrorSuppressor()); + }; break; + case 'context_bookmarkTab': + Commands.bookmarkTab(contextTab); + break; + case 'context_bookmarkSelected': + Commands.bookmarkTab(contextTab || activeTab); + break; + case 'context_closeDuplicatedTabs': { + const tabs = await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler()); + tabs.sort((a, b) => b.lastAccessed - a.lastAccessed); + const tabKeys = new Set(); + const closeTabs = []; + for (const tab of tabs) { + const key = `${tab.cookieStoreId}\n${tab.url}`; + if (tabKeys.has(key)) { + closeTabs.push(Tab.get(tab.id)); + continue; + } + tabKeys.add(key); + } + const canceled = (await browser.runtime.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TABS_CLOSING, + tabs: closeTabs.map(tab => tab.$TST.sanitized), + windowId, + }).catch(ApiTabs.createErrorHandler())) === false; + if (canceled) + break; + TabsInternalOperation.removeTabs(closeTabs); + } break; + case 'context_closeTabsToTheStart': { + const tabs = await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler()); + const closeTabs = []; + const keptTabIds = new Set( + multiselectedTabs ? + multiselectedTabs.map(tab => tab.id) : + [contextTab.id] + ); + for (const tab of tabs) { + if (keptTabIds.has(tab.id)) + break; + if (!tab.pinned && !tab.hidden) + closeTabs.push(Tab.get(tab.id)); + } + const canceled = (await browser.runtime.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TABS_CLOSING, + tabs: closeTabs.map(tab => tab.$TST.sanitized), + windowId + }).catch(ApiTabs.createErrorHandler())) === false; + if (canceled) + break; + TabsInternalOperation.removeTabs(closeTabs); + }; break; + case 'context_closeTabsToTheEnd': { + const tabs = await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler()); + tabs.reverse(); + const closeTabs = []; + const keptTabIds = new Set( + (multiselectedTabs ? + multiselectedTabs : + [contextTab] + ).reduce((tabIds, tab, _index) => { + if (tab.$TST.subtreeCollapsed) + tabIds.push(tab.id, ...tab.$TST.descendants.map(tab => tab.id)) + else + tabIds.push(tab.id); + return tabIds; + }, []) + ); + for (const tab of tabs) { + if (keptTabIds.has(tab.id)) + break; + if (!tab.pinned && !tab.hidden) + closeTabs.push(Tab.get(tab.id)); + } + closeTabs.reverse(); + const canceled = (await browser.runtime.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TABS_CLOSING, + tabs: closeTabs.map(tab => tab.$TST.sanitized), + windowId + }).catch(ApiTabs.createErrorHandler())) === false; + if (canceled) + break; + TabsInternalOperation.removeTabs(closeTabs); + }; break; + case 'context_closeOtherTabs': { + const tabs = await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler()); + const keptTabIds = new Set( + (multiselectedTabs ? + multiselectedTabs : + [contextTab] + ).reduce((tabIds, tab, _index) => { + if (tab.$TST.subtreeCollapsed) + tabIds.push(tab.id, ...tab.$TST.descendants.map(tab => tab.id)) + else + tabIds.push(tab.id); + return tabIds; + }, []) + ); + const closeTabs = mapAndFilter(tabs, + tab => !tab.pinned && !tab.hidden && !keptTabIds.has(tab.id) && Tab.get(tab.id) || undefined); + const canceled = (await browser.runtime.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TABS_CLOSING, + tabs: closeTabs.map(tab => tab.$TST.sanitized), + windowId + }).catch(ApiTabs.createErrorHandler())) === false; + if (canceled) + break; + TabsInternalOperation.removeTabs(closeTabs); + }; break; + case 'context_undoCloseTab': { + const sessions = await browser.sessions.getRecentlyClosed({ maxResults: 1 }).catch(ApiTabs.createErrorHandler()); + if (sessions.length && sessions[0].tab) + browser.sessions.restore(sessions[0].tab.sessionId).catch(ApiTabs.createErrorSuppressor()); + }; break; + case 'context_closeTab': { + const closeTabs = (multiselectedTabs || TreeBehavior.getClosingTabsFromParent(contextTab, { + byInternalOperation: true + })).reverse(); // close down to top, to keep tree structure of Tree Style Tab + const canceled = (await browser.runtime.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TABS_CLOSING, + tabs: closeTabs.map(tab => tab.$TST.sanitized), + windowId + }).catch(ApiTabs.createErrorHandler())) === false; + if (canceled) + return; + TabsInternalOperation.removeTabs(closeTabs); + }; break; + + default: { + const nativeTabGroupMatch = info.menuItemId.match(/^context_addToGroup:group:(.+)$/); + if (contextTab && + nativeTabGroupMatch) + NativeTabGroups.addTabsToGroup(multiselectedTabs || [contextTab], parseInt(nativeTabGroupMatch[1])); + + const contextualIdentityMatch = info.menuItemId.match(/^context_reopenInContainer:(.+)$/); + if (contextTab && + contextualIdentityMatch) + Commands.reopenInContainer(contextTab, contextualIdentityMatch[1]); + + const shareTabsMatch = info.menuItemId.match(/^context_shareTabURL:service:(.+)$/); + if (mSharingService && + contextTab && + shareTabsMatch) + mSharingService.share(contextTab, shareTabsMatch[1]); + + const sendTabsToDeviceMatch = info.menuItemId.match(/^context_sendTabsToDevice:device:(.+)$/); + if (contextTab && + sendTabsToDeviceMatch) + Sync.sendTabsToDevice( + multiselectedTabs || [contextTab], + { to: sendTabsToDeviceMatch[1] } + ); + const sendTreeToDeviceMatch = info.menuItemId.match(/^context_topLevel_sendTreeToDevice:device:(.+)$/); + if (contextTab && + sendTreeToDeviceMatch) + Sync.sendTabsToDevice( + multiselectedTabs || [contextTab], + { to: sendTreeToDeviceMatch[1], + recursively: true } + ); + + if (EXTERNAL_TOP_LEVEL_ITEM_MATCHER.test(info.menuItemId)) { + const owner = RegExp.$1; + const menuItemId = RegExp.$2; + const message = { + type: TSTAPI.kCONTEXT_MENU_CLICK, + info: { + bookmarkId: info.bookmarkId || null, + button: info.button, + checked: info.checked, + editable: false, + frameId: null, + frameUrl: null, + linkText: null, + linkUrl: null, + mediaType: null, + menuItemId, + modifiers: [], + pageUrl: null, + parentMenuItemId: null, + selectionText: null, + srcUrl: null, + targetElementId: null, + viewType: 'sidebar', + wasChecked: info.wasChecked + }, + tab: contextTab, + }; + if (owner == browser.runtime.id) { + browser.runtime.sendMessage(message).catch(ApiTabs.createErrorSuppressor()); + } + else { + const cache = {}; + TSTAPI.sendMessage( + owner, + message, + { tabProperties: ['tab'], cache, isContextTab: true } + ).catch(ApiTabs.createErrorSuppressor()); + TSTAPI.sendMessage( + owner, + { + ...message, + type: TSTAPI.kFAKE_CONTEXT_MENU_CLICK + }, + { tabProperties: ['tab'], cache, isContextTab: true } + ).catch(ApiTabs.createErrorSuppressor()); + } + } + }; break; + } +} + + +function getItemsFor(addonId) { + if (mExtraItems.has(addonId)) { + return mExtraItems.get(addonId); + } + const items = []; + mExtraItems.set(addonId, items); + return items; +} + +function exportExtraItems() { + const exported = {}; + for (const [id, items] of mExtraItems.entries()) { + exported[id] = items; + } + return exported; +} + +async function notifyUpdated() { + await browser.runtime.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_CONTEXT_MENU_UPDATED, + items: exportExtraItems() + }).catch(ApiTabs.createErrorSuppressor()); +} + +let mReservedNotifyUpdate; +let mNotifyUpdatedHandlers = []; + +function reserveNotifyUpdated() { + return new Promise((resolve, _aReject) => { + mNotifyUpdatedHandlers.push(resolve); + if (mReservedNotifyUpdate) + clearTimeout(mReservedNotifyUpdate); + mReservedNotifyUpdate = setTimeout(async () => { + mReservedNotifyUpdate = undefined; + await notifyUpdated(); + const handlers = mNotifyUpdatedHandlers; + mNotifyUpdatedHandlers = []; + for (const handler of handlers) { + handler(); + } + }, 10); + }); +} + +function reserveRefresh() { + if (reserveRefresh.reserved) + clearTimeout(reserveRefresh.reserved); + reserveRefresh.reserved = setTimeout(() => { + reserveRefresh.reserved = null;; + browser.menus.refresh(); + }, 0); +} + +function onMessage(message, _sender) { + if (!mInitialized) + return; + + log('tab-context-menu: internally called:', message); + switch (message.type) { + case Constants.kCOMMAND_GET_CONTEXT_MENU_ITEMS: + return Promise.resolve(exportExtraItems()); + + case TSTAPI.kCONTEXT_MENU_CLICK: + onTSTItemClick.dispatch(message.info, message.tab); + return; + + case TSTAPI.kCONTEXT_MENU_SHOWN: + onShown(message.info, message.tab); + onTSTTabContextMenuShown.dispatch(message.info, message.tab); + return; + + case TSTAPI.kCONTEXT_MENU_HIDDEN: + onTSTTabContextMenuHidden.dispatch(); + return; + + case Constants.kCOMMAND_NOTIFY_CONTEXT_ITEM_CHECKED_STATUS_CHANGED: + for (const itemData of mExtraItems.get(message.ownerId)) { + if (!itemData.id != message.id) + continue; + itemData.checked = message.checked; + break; + } + return; + + case Constants.kCOMMAND_NOTIFY_CONTEXT_OVERRIDDEN: + mOverriddenContext = message.context || null; + if (mOverriddenContext) { + mOverriddenContext.owner = message.owner; + mOverriddenContext.windowId = message.windowId; + } + break; + + // For optimization we update the context menu before the menu is actually opened if possible, to reduce visual flickings. + // But this optimization does not work as expected on environments which shows the context menu with mousedown, like macOS. + case TSTAPI.kNOTIFY_TAB_MOUSEDOWN: + if (message.button == 2) { + onShown( + message, + message.tab, + ); + onTSTTabContextMenuShown.dispatch(message, message.tab); + } + break; + } +} + +export function onMessageExternal(message, sender) { + if (!mInitialized) + return; + + switch (message.type) { + case TSTAPI.kCONTEXT_MENU_CREATE: + case TSTAPI.kFAKE_CONTEXT_MENU_CREATE: { + log('TSTAPI.kCONTEXT_MENU_CREATE:', message, { id: sender.id, url: sender.url }); + const items = getItemsFor(sender.id); + let params = message.params; + if (Array.isArray(params)) + params = params[0]; + const parent = params.parentId && items.filter(item => item.id == params.parentId)[0]; + if (params.parentId && !parent) + break; + let shouldAdd = true; + if (params.id) { + for (let i = 0, maxi = items.length; i < maxi; i++) { + const item = items[i]; + if (item.id != params.id) + continue; + items.splice(i, 1, params); + shouldAdd = false; + break; + } + } + if (shouldAdd) { + items.push(params); + if (parent?.id) { + parent.children = parent.children || []; + parent.children.push(params.id); + } + } + mExtraItems.set(sender.id, items); + params.$topLevel = ( + Array.isArray(params.viewTypes) && + params.viewTypes.includes('sidebar') + ); + if (sender.id != browser.runtime.id && + params.$topLevel) { + params.lastVisible = params.visible !== false; + const visible = !!( + params.lastVisible && + mOverriddenContext && + mLastOverriddenContextOwner == sender.id + ); + const createParams = { + id: getExternalTopLevelItemId(sender.id, params.id), + type: params.type || 'normal', + visible, + viewTypes: ['sidebar', 'tab', 'popup'], + contexts: (params.contexts || []).filter(context => context == 'tab' || context == 'bookmark'), + documentUrlPatterns: SIDEBAR_URL_PATTERN + }; + if (params.parentId) + createParams.parentId = getExternalTopLevelItemId(sender.id, params.parentId); + for (const property of SAFE_MENU_PROPERTIES) { + if (property in params) + createParams[property] = params[property]; + } + browser.menus.create(createParams); + reserveRefresh(); + onTopLevelItemAdded.dispatch(); + } + return reserveNotifyUpdated(); + }; break; + + case TSTAPI.kCONTEXT_MENU_UPDATE: + case TSTAPI.kFAKE_CONTEXT_MENU_UPDATE: { + log('TSTAPI.kCONTEXT_MENU_UPDATE:', message, { id: sender.id, url: sender.url }); + const items = getItemsFor(sender.id); + for (let i = 0, maxi = items.length; i < maxi; i++) { + const item = items[i]; + if (item.id != message.params[0]) + continue; + const params = message.params[1]; + const updateProperties = {}; + for (const property of SAFE_MENU_PROPERTIES) { + if (property in params) + updateProperties[property] = params[property]; + } + if (sender.id != browser.runtime.id && + item.$topLevel) { + if ('visible' in updateProperties) + item.lastVisible = updateProperties.visible; + if (!mOverriddenContext || + mLastOverriddenContextOwner != sender.id) + delete updateProperties.visible; + } + items.splice(i, 1, { + ...item, + ...updateProperties + }); + if (sender.id != browser.runtime.id && + item.$topLevel && + Object.keys(updateProperties).length > 0) { + browser.menus.update( + getExternalTopLevelItemId(sender.id, item.id), + updateProperties + ); + reserveRefresh() + } + break; + } + mExtraItems.set(sender.id, items); + return reserveNotifyUpdated(); + }; break; + + case TSTAPI.kCONTEXT_MENU_REMOVE: + case TSTAPI.kFAKE_CONTEXT_MENU_REMOVE: { + log('TSTAPI.kCONTEXT_MENU_REMOVE:', message, { id: sender.id, url: sender.url }); + let items = getItemsFor(sender.id); + let id = message.params; + if (Array.isArray(id)) + id = id[0]; + const item = items.filter(item => item.id == id)[0]; + if (!item) + break; + const parent = item.parentId && items.filter(item => item.id == item.parentId)[0]; + items = items.filter(item => item.id != id); + mExtraItems.set(sender.id, items); + if (parent?.children) + parent.children = parent.children.filter(childId => childId != id); + if (item.children) { + for (const childId of item.children) { + onMessageExternal({ type: message.type, params: childId }, sender); + } + } + if (sender.id != browser.runtime.id && + item.$topLevel) { + browser.menus.remove(getExternalTopLevelItemId(sender.id, item.id)); + reserveRefresh(); + } + return reserveNotifyUpdated(); + }; break; + + case TSTAPI.kCONTEXT_MENU_REMOVE_ALL: + case TSTAPI.kFAKE_CONTEXT_MENU_REMOVE_ALL: + case TSTAPI.kUNREGISTER_SELF: { + delete mExtraItems.delete(sender.id); + return reserveNotifyUpdated(); + }; break; + } +} diff --git a/waterfox/browser/components/sidebar/background/tabs-group.js b/waterfox/browser/components/sidebar/background/tabs-group.js new file mode 100644 index 000000000000..1ae6f338dbdc --- /dev/null +++ b/waterfox/browser/components/sidebar/background/tabs-group.js @@ -0,0 +1,662 @@ +/* +# 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'; + +import { + log as internalLogger, + configs, + wait, + countMatched, + dumpTab +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from '/common/constants.js'; +import * as SidebarConnection from '/common/sidebar-connection.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TabsInternalOperation from '/common/tabs-internal-operation.js'; +import * as TabsUpdate from '/common/tabs-update.js'; +import * as TreeBehavior from '/common/tree-behavior.js'; + +import { Tab } from '/common/TreeItem.js'; + +import * as TabsMove from './tabs-move.js'; +import * as TabsOpen from './tabs-open.js'; +import * as Tree from './tree.js'; + +function log(...args) { + internalLogger('background/tabs-group', ...args); +} + +export function makeGroupTabURI({ title, temporary, temporaryAggressive, openerTabId, aliasTabId, replacedParentCount } = {}) { + const url = new URL(Constants.kGROUP_TAB_URI); + + if (title) + url.searchParams.set('title', title); + + if (temporaryAggressive) + url.searchParams.set('temporaryAggressive', 'true'); + else if (temporary) + url.searchParams.set('temporary', 'true'); + + if (openerTabId) + url.searchParams.set('openerTabId', openerTabId); + + if (aliasTabId) + url.searchParams.set('aliasTabId', aliasTabId); + + if (replacedParentCount) + url.searchParams.set('replacedParentCount', replacedParentCount); + + return url.href; +} + +export function temporaryStateParams(state) { + switch (state) { + case Constants.kGROUP_TAB_TEMPORARY_STATE_PASSIVE: + return { + temporary: true, + temporaryAggressive: false, + }; + case Constants.kGROUP_TAB_TEMPORARY_STATE_AGGRESSIVE: + return { + temporary: false, + temporaryAggressive: true, + }; + default: + break; + } + return { + temporary: false, + temporaryAggressive: false, + }; +} + +export async function groupTabs(tabs, { broadcast, parent, withDescendants, ...groupTabOptions } = {}) { + const rootTabs = Tab.collectRootTabs(tabs); + if (rootTabs.length <= 0) + return null; + + log('groupTabs: ', () => tabs.map(dumpTab), { broadcast, parent, withDescendants }); + + const uri = makeGroupTabURI({ + title: browser.i18n.getMessage('groupTab_label', rootTabs[0].title), + temporary: true, + ...groupTabOptions + }); + const groupTab = await TabsOpen.openURIInTab(uri, { + windowId: rootTabs[0].windowId, + parent: parent || rootTabs[0].$TST.parent, + insertBefore: rootTabs[0], + inBackground: true + }); + + if (!withDescendants) { + const structure = TreeBehavior.getTreeStructureFromTabs(tabs); + + await Tree.detachTabsFromTree(tabs, { + broadcast: !!broadcast, + }); + + log('structure: ', structure); + await Tree.applyTreeStructureToTabs(tabs, structure, { + broadcast: !!broadcast, + }); + } + + await TabsMove.moveTabsAfter(tabs.slice(1), tabs[0], { + broadcast: !!broadcast + }); + for (const tab of rootTabs) { + await Tree.attachTabTo(tab, groupTab, { + forceExpand: true, // this is required to avoid the group tab itself is active from active tab in collapsed tree + dontMove: true, + broadcast: !!broadcast, + }); + } + return groupTab; +} + +function reserveToCleanupNeedlessGroupTab(tabOrTabs) { + const tabs = Array.isArray(tabOrTabs) ? tabOrTabs : [tabOrTabs] ; + for (const tab of tabs) { + if (!TabsStore.ensureLivingItem(tab)) + continue; + if (tab.$TST.temporaryMetadata.has('reservedCleanupNeedlessGroupTab')) + clearTimeout(tab.$TST.temporaryMetadata.get('reservedCleanupNeedlessGroupTab')); + tab.$TST.temporaryMetadata.set('reservedCleanupNeedlessGroupTab', setTimeout(() => { + if (!tab.$TST) + return; + tab.$TST.temporaryMetadata.delete('reservedCleanupNeedlessGroupTab'); + cleanupNeedlssGroupTab(tab); + }, 100)); + } +} + +function cleanupNeedlssGroupTab(tabs) { + if (!Array.isArray(tabs)) + tabs = [tabs]; + log('trying to clanup needless temporary group tabs from ', () => tabs.map(dumpTab)); + const tabsToBeRemoved = []; + for (const tab of tabs) { + if (tab.$TST.temporaryMetadata.has('movingAcrossWindows')) + continue; + + if (tab.$TST.isTemporaryGroupTab) { + if (tab.$TST.childIds.length > 1) + break; + const lastChild = tab.$TST.firstChild; + if (lastChild && + !lastChild.$TST.isTemporaryGroupTab && + !lastChild.$TST.isTemporaryAggressiveGroupTab) + break; + } + else if (tab.$TST.isTemporaryAggressiveGroupTab) { + if (tab.$TST.childIds.length > 1) + break; + } + else { + break; + } + tabsToBeRemoved.push(tab); + } + log('=> to be removed: ', () => tabsToBeRemoved.map(dumpTab)); + TabsInternalOperation.removeTabs(tabsToBeRemoved, { keepDescendants: true }); +} + +export async function tryReplaceTabWithGroup(tab, { windowId, parent, children, insertBefore, newParent } = {}) { + if (tab) { + windowId = tab.windowId; + parent = tab.$TST.parent; + children = tab.$TST.children; + insertBefore = insertBefore || tab.$TST.unsafeNextTab; + } + + if (children.length <= 1 || + countMatched(children, + tab => !tab.$TST.states.has(Constants.kTAB_STATE_TO_BE_REMOVED)) <= 1) + return null; + + log('trying to replace the closing tab with a new group tab'); + + const firstChild = children[0]; + const uri = makeGroupTabURI({ + title: browser.i18n.getMessage('groupTab_label', firstChild.title), + ...temporaryStateParams(configs.groupTabTemporaryStateForOrphanedTabs), + replacedParentCount: (tab?.$TST?.replacedParentGroupTabCount || 0) + 1, + }); + const win = TabsStore.windows.get(windowId); + win.toBeOpenedTabsWithPositions++; + const groupTab = await TabsOpen.openURIInTab(uri, { + windowId, + insertBefore, + inBackground: true + }); + log('group tab: ', dumpTab(groupTab)); + if (!groupTab) // the window is closed! + return; + if (newParent || parent) + await Tree.attachTabTo(groupTab, newParent || parent, { + dontMove: true, + broadcast: true + }); + for (const child of children) { + await Tree.attachTabTo(child, groupTab, { + dontMove: true, + broadcast: true + }); + } + + // This can be triggered on closing of multiple tabs, + // so we should cleanup it on such cases for safety. + // https://github.com/piroor/treestyletab/issues/2317 + wait(1000).then(() => reserveToCleanupNeedlessGroupTab(groupTab)); + + return groupTab; +} + + +// ==================================================================== +// init/update group tabs +// ==================================================================== + +/* + To prevent the tab is closed by Firefox, we need to inject scripts dynamically. + See also: https://github.com/piroor/treestyletab/issues/1670#issuecomment-350964087 +*/ +async function tryInitGroupTab(tab) { + if (!tab.$TST.isGroupTab && + !tab.$TST.hasGroupTabURL) + return; + log('tryInitGroupTab ', tab); + const v3Options = { + target: { tabId: tab.id }, + }; + const v2Options = { + runAt: 'document_start', + matchAboutBlank: true + }; + try { + const getPageState = function getPageState() { + return [window.prepared, document.documentElement.matches('.initialized')]; + }; + const [prepared, initialized, reloaded] = (browser.scripting ? + browser.scripting.executeScript({ // Manifest V3 + ...v3Options, + func: getPageState, + }).then(results => results && results[0] && results[0].result || []) : + browser.tabs.executeScript(tab.id, { + ...v2Options, + code: `(${getPageState.toString()})()`, + }).then(results => results && results[0] || []) + ).catch(error => { + if (ApiTabs.isMissingHostPermissionError(error) && + tab.$TST.hasGroupTabURL) { + log(' tryInitGroupTab: failed to run script for restored/discarded tab, reload the tab for safety ', tab.id); + browser.tabs.reload(tab.id); + return [[false, false, true]]; + } + return ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)(error); + }); + log(' tryInitGroupTab: groupt tab state ', tab.id, { prepared, initialized, reloaded }); + if (reloaded) { + log(' => reloaded ', tab.id); + return; + } + if (prepared && initialized) { + log(' => already initialized ', tab.id); + return; + } + } + catch(error) { + log(' tryInitGroupTab: error while checking initialized: ', tab.id, error); + } + try { + const getTitleExistence = function getState() { + return !!document.querySelector('#title'); + }; + const titleElementExists = (browser.scripting ? + browser.scripting.executeScript({ // Manifest V3 + ...v3Options, + func: getTitleExistence, + }).then(results => results && results[0] && results[0].result) : + browser.tabs.executeScript(tab.id, { + ...v2Options, + code: `(${getTitleExistence.toString()})()`, + }).then(results => results && results[0]) + ).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError, ApiTabs.handleMissingHostPermissionError)); + if (!titleElementExists && tab.status == 'complete') { // we need to load resources/group-tab.html at first. + log(' => title element exists, load again ', tab.id); + return browser.tabs.update(tab.id, { url: tab.url }).catch(ApiTabs.createErrorSuppressor()); + } + } + catch(error) { + log(' tryInitGroupTab error while checking title element: ', tab.id, error); + } + + (browser.scripting ? + browser.scripting.executeScript({ // Manifest V3 + ...v3Options, + files: [ + '/extlib/l10n-classic.js', // ES module does not supported as a content script... + '/resources/group-tab.js', + ], + }) : + Promise.all([ + browser.tabs.executeScript(tab.id, { + ...v2Options, + //file: '/common/l10n.js' + file: '/extlib/l10n-classic.js', // ES module does not supported as a content script... + }).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError, ApiTabs.handleMissingHostPermissionError)), + browser.tabs.executeScript(tab.id, { + ...v2Options, + file: '/resources/group-tab.js', + }).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError, ApiTabs.handleMissingHostPermissionError)), + ]) + ).then(() => { + log('tryInitGroupTab completely initialized: ', tab.id); + }); + + if (tab.$TST.states.has(Constants.kTAB_STATE_UNREAD)) { + tab.$TST.removeState(Constants.kTAB_STATE_UNREAD, { permanently: true }); + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_UPDATED, + windowId: tab.windowId, + tabId: tab.id, + removedStates: [Constants.kTAB_STATE_UNREAD] + }); + } +} + +function reserveToUpdateRelatedGroupTabs(tab, changedInfo) { + const tabMetadata = tab.$TST.temporaryMetadata; + const updatingTabs = tabMetadata.get('reserveToUpdateRelatedGroupTabsUpdatingTabs') || new Set(); + if (!tabMetadata.has('reserveToUpdateRelatedGroupTabsUpdatingTabs')) + tabMetadata.set('reserveToUpdateRelatedGroupTabsUpdatingTabs', updatingTabs); + + const ancestorGroupTabs = [ + tab, + tab.$TST.bundledTab, + ...tab.$TST.ancestors, + ...tab.$TST.ancestors.map(tab => tab.$TST.bundledTab), + ].filter(tab => tab?.$TST.isGroupTab); + for (const updatingTab of ancestorGroupTabs) { + const updatingMetadata = updatingTab.$TST.temporaryMetadata; + const reservedChangedInfo = updatingMetadata.get('reservedUpdateRelatedGroupTabChangedInfo') || new Set(); + for (const info of changedInfo) { + reservedChangedInfo.add(info); + } + if (updatingTabs.has(updatingTab.id)) + continue; + updatingTabs.add(updatingTab.id); + const triggeredUpdates = updatingMetadata.get('reservedUpdateRelatedGroupTabTriggeredUpdates') || new Set(); + triggeredUpdates.add(updatingTabs); + updatingMetadata.set('reservedUpdateRelatedGroupTabTriggeredUpdates', triggeredUpdates); + if (updatingMetadata.has('reservedUpdateRelatedGroupTab')) + clearTimeout(updatingMetadata.get('reservedUpdateRelatedGroupTab')); + updatingMetadata.set('reservedUpdateRelatedGroupTabChangedInfo', reservedChangedInfo); + updatingMetadata.set('reservedUpdateRelatedGroupTab', setTimeout(() => { + updatingMetadata.delete('reservedUpdateRelatedGroupTab'); + if (updatingTab.$TST) { + try { + if (reservedChangedInfo.size > 0) + updateRelatedGroupTab(updatingTab, [...reservedChangedInfo]); + } + catch(_error) { + } + updatingMetadata.delete('reservedUpdateRelatedGroupTabChangedInfo'); + } + setTimeout(() => { + const triggerUpdates = updatingMetadata.get('reservedUpdateRelatedGroupTabTriggeredUpdates') + updatingMetadata.delete('reservedUpdateRelatedGroupTabTriggeredUpdates'); + if (!triggerUpdates) + return; + for (const updatingTabs of triggerUpdates) { + updatingTabs.delete(updatingTab.id); + } + }, 100) + }, 100)); + } +} + +async function updateRelatedGroupTab(groupTab, changedInfo = []) { + if (!TabsStore.ensureLivingItem(groupTab)) + return; + + await tryInitGroupTab(groupTab); + if (changedInfo.includes('tree')) { + try { + await browser.tabs.sendMessage(groupTab.id, { + type: 'ws:update-tree', + }).catch(error => { + if (ApiTabs.isMissingHostPermissionError(error)) + throw error; + return ApiTabs.createErrorSuppressor(ApiTabs.handleMissingTabError, ApiTabs.handleUnloadedError)(error); + }); + } + catch(error) { + if (ApiTabs.isMissingHostPermissionError(error)) { + log(' updateRelatedGroupTab: failed to run script for restored/discarded tab, reload the tab for safety ', groupTab.id); + browser.tabs.reload(groupTab.id); + return; + } + } + } + + const firstChild = groupTab.$TST.firstChild; + if (!firstChild) // the tab can be closed while waiting... + return; + + if (changedInfo.includes('title')) { + let newTitle; + if (Constants.kGROUP_TAB_DEFAULT_TITLE_MATCHER.test(groupTab.title)) { + newTitle = browser.i18n.getMessage('groupTab_label', firstChild.title); + } + else if (Constants.kGROUP_TAB_FROM_PINNED_DEFAULT_TITLE_MATCHER.test(groupTab.title)) { + const opener = groupTab.$TST.openerTab; + if (opener) { + if (opener && + opener.favIconUrl) { + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_FAVICON_UPDATED, + windowId: groupTab.windowId, + tabId: groupTab.id, + favIconUrl: opener.favIconUrl + }); + } + newTitle = browser.i18n.getMessage('groupTab_fromPinnedTab_label', opener.title); + } + } + + if (newTitle && groupTab.title != newTitle) { + browser.tabs.sendMessage(groupTab.id, { + type: 'ws:update-title', + title: newTitle, + }).catch(ApiTabs.createErrorHandler( + ApiTabs.handleMissingTabError, + ApiTabs.handleMissingHostPermissionError, + _error => { + // failed to update the title by group tab itself, so we try to update it from outside + groupTab.title = newTitle; + TabsUpdate.updateTab(groupTab, { title: newTitle }); + } + )); + } + } +} + +Tab.onRemoved.addListener((tab, _closeInfo = {}) => { + const ancestors = tab.$TST.ancestors; + wait(0).then(() => { + reserveToCleanupNeedlessGroupTab(ancestors); + }); +}); + +Tab.onUpdated.addListener((tab, changeInfo) => { + if ('url' in changeInfo || + 'previousUrl' in changeInfo || + 'state' in changeInfo) { + const status = changeInfo.status || tab?.status; + const url = changeInfo.url ? changeInfo.url : + status == 'complete' && tab ? tab.url : ''; + if (tab && + status == 'complete') { + if (url.indexOf(Constants.kGROUP_TAB_URI) == 0) { + tab.$TST.addState(Constants.kTAB_STATE_GROUP_TAB, { permanently: true }); + } + else if (!Constants.kSHORTHAND_ABOUT_URI.test(url)) { + tab.$TST.getPermanentStates().then(async (states) => { + if (url.indexOf(Constants.kGROUP_TAB_URI) == 0) + return; + // Detect group tab from different session - which can have different UUID for the URL. + const PREFIX_REMOVER = /^moz-extension:\/\/[^\/]+/; + const pathPart = url.replace(PREFIX_REMOVER, ''); + if (states.includes(Constants.kTAB_STATE_GROUP_TAB) && + pathPart.split('?')[0] == Constants.kGROUP_TAB_URI.replace(PREFIX_REMOVER, '')) { + const parameters = pathPart.replace(/^[^\?]+\?/, ''); + const oldUrl = tab.url; + await wait(100); // for safety + if (tab.url != oldUrl) + return; + browser.tabs.update(tab.id, { + url: `${Constants.kGROUP_TAB_URI}?${parameters}` + }).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + tab.$TST.addState(Constants.kTAB_STATE_GROUP_TAB); + } + else { + tab.$TST.removeState(Constants.kTAB_STATE_GROUP_TAB, { permanently: true }); + } + }); + } + } + // restored tab can be replaced with blank tab. we need to restore it manually. + else if (changeInfo.url == 'about:blank' && + changeInfo.previousUrl && + changeInfo.previousUrl.indexOf(Constants.kGROUP_TAB_URI) == 0) { + const oldUrl = tab.url; + wait(100).then(() => { // redirect with delay to avoid infinite loop of recursive redirections. + if (tab.url != oldUrl) + return; + browser.tabs.update(tab.id, { + url: changeInfo.previousUrl + }).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + tab.$TST.addState(Constants.kTAB_STATE_GROUP_TAB, { permanently: true }); + }); + } + + if (changeInfo.status || + changeInfo.url || + url.indexOf(Constants.kGROUP_TAB_URI) == 0) + tryInitGroupTab(tab); + } + + if ('title' in changeInfo) { + const group = Tab.getGroupTabForOpener(tab); + if (group) + reserveToUpdateRelatedGroupTabs(group, ['title', 'tree']); + } +}); + +Tab.onGroupTabDetected.addListener(tab => { + tryInitGroupTab(tab); +}); + +Tab.onLabelUpdated.addListener(tab => { + reserveToUpdateRelatedGroupTabs(tab, ['title', 'tree']); +}); + +Tab.onActivating.addListener((tab, _info = {}) => { + tryInitGroupTab(tab); +}); + +// returns a boolean: need to reload or not. +export async function clearTemporaryState(tab) { + if (!tab.$TST.isTemporaryGroupTab && + !tab.$TST.isTemporaryAggressiveGroupTab) + return; + + const url = new URL(tab.url); + url.searchParams.delete('temporary'); + url.searchParams.delete('temporaryAggressive'); + await Promise.all([ + browser.tabs.sendMessage(tab.id, { + type: 'ws:clear-temporary-state', + }).catch(ApiTabs.createErrorHandler()), + browser.tabs.executeScript(tab.id, { // failsafe + runAt: 'document_start', + code: `history.replaceState({}, document.title, ${JSON.stringify(url.href)});`, + }).catch(ApiTabs.createErrorHandler()), + ]); + tab.url = url.href; +} + +Tab.onPinned.addListener(async tab => { + log('handlePinnedParentTab ', tab); + + await Tree.collapseExpandSubtree(tab, { + collapsed: false, + broadcast: true + }); + + log(' childIdsBeforeMoved: ', tab.$TST.temporaryMetadata.get('childIdsBeforeMoved')); + log(' parentIdBeforeMoved: ', tab.$TST.temporaryMetadata.get('parentIdBeforeMoved')); + + const children = ( + tab.$TST.temporaryMetadata.has('childIdsBeforeMoved') ? + tab.$TST.temporaryMetadata.get('childIdsBeforeMoved').map(id => Tab.get(id)) : + tab.$TST.children + ).filter(tab => TabsStore.ensureLivingItem(tab)); + const parent = TabsStore.ensureLivingItem( + tab.$TST.temporaryMetadata.has('parentIdBeforeMoved') ? + Tab.get(tab.$TST.temporaryMetadata.get('parentIdBeforeMoved')) : + tab.$TST.parent + ); + + let openedGroupTab; + const shouldGroupChildren = configs.autoGroupNewTabsFromPinned || tab.$TST.isGroupTab; + if (shouldGroupChildren) { + log(' => trying to group left tabs with a group: ', children); + openedGroupTab = await groupTabs(children, { + // If the tab is a group tab, the opened tab should be treated as an alias of the pinned group tab. + // Otherwise it should be treated just as a temporary group tab to group children. + title: tab.$TST.isGroupTab ? tab.title : browser.i18n.getMessage('groupTab_fromPinnedTab_label', tab.title), + temporary: !tab.$TST.isGroupTab, + openerTabId: tab.$TST.uniqueId.id, + parent, + withDescendants: true, + }); + log(' openedGroupTab: ', openedGroupTab); + // Tree structure of left tabs can be modified by someone like tryFixupTreeForInsertedTab@handle-moved-tabs.js. + // On such cases we need to restore the original tree structure. + const modifiedChildren = children.filter(child => children.includes(child.$TST.parent)); + log(' modifiedChildren: ', modifiedChildren); + if (modifiedChildren.length > 0) { + for (const child of modifiedChildren) { + await Tree.detachTab(child, { + broadcast: true, + }); + await Tree.attachTabTo(child, openedGroupTab, { + dontMove: true, + broadcast: true, + }); + } + } + } + else { + log(' => no need to group left tabs, just detaching'); + await Tree.detachAllChildren(tab, { + behavior: TreeBehavior.getParentTabOperationBehavior(tab, { + context: Constants.kPARENT_TAB_OPERATION_CONTEXT_CLOSE, + preventEntireTreeBehavior: true, + }), + broadcast: true + }); + } + await Tree.detachTab(tab, { + broadcast: true + }); + + // Such a group tab will be closed automatically when all children are detached. + // To prevent the auto close behavior, the tab type need to be turned to permanent. + await clearTemporaryState(tab); + + if (tab.$TST.isGroupTab && openedGroupTab) { + const url = new URL(tab.url); + url.searchParams.set('aliasTabId', openedGroupTab.$TST.uniqueId.id); + await Promise.all([ + browser.tabs.sendMessage(tab.id, { + type: 'ws:replace-state-url', + url: url.href, + }).catch(ApiTabs.createErrorHandler()), + browser.tabs.executeScript(tab.id, { // failsafe + runAt: 'document_start', + code: `history.replaceState({}, document.title, ${JSON.stringify(url.href)});`, + }).catch(ApiTabs.createErrorHandler()), + ]); + await browser.tabs.sendMessage(tab.id, { + type: 'ws:update-tree', + url: url.href, + }).catch(ApiTabs.createErrorHandler()); + tab.url = url.href; + } +}); + +Tree.onAttached.addListener((tab, _info = {}) => { + reserveToUpdateRelatedGroupTabs(tab, ['tree']); +}); + +Tree.onDetached.addListener((_tab, detachInfo) => { + if (!detachInfo.oldParentTab) + return; + if (detachInfo.oldParentTab.$TST.isGroupTab) + reserveToCleanupNeedlessGroupTab(detachInfo.oldParentTab); + reserveToUpdateRelatedGroupTabs(detachInfo.oldParentTab, ['tree']); +}); + +/* +Tree.onSubtreeCollapsedStateChanging.addListener((tab, _info) => { + reserveToUpdateRelatedGroupTabs(tab); +}); +*/ diff --git a/waterfox/browser/components/sidebar/background/tabs-move.js b/waterfox/browser/components/sidebar/background/tabs-move.js new file mode 100644 index 000000000000..fb5cded2a63f --- /dev/null +++ b/waterfox/browser/components/sidebar/background/tabs-move.js @@ -0,0 +1,465 @@ +/* ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is the Tree Style Tab. + * + * The Initial Developer of the Original Code is YUKI "Piro" Hiroshi. + * Portions created by the Initial Developer are Copyright (C) 2011-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): YUKI "Piro" Hiroshi + * wanabe + * Tetsuharu OHZEKI + * Xidorn Quan (Firefox 40+ support) + * lv7777 (https://github.com/lv7777) + * + * ***** END LICENSE BLOCK ******/ +'use strict'; + +import { + log as internalLogger, + wait, + toLines, + configs +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from '/common/constants.js'; +import { SequenceMatcher } from '/extlib/diff.js'; +import * as SidebarConnection from '/common/sidebar-connection.js'; +import * as TabsStore from '/common/tabs-store.js'; + +import { Tab, TreeItem } from '/common/TreeItem.js'; + +function log(...args) { + internalLogger('background/tabs-move', ...args); +} +function logApiTabs(...args) { + internalLogger('common/api-tabs', ...args); +} + + +// ======================================================== +// primitive methods for internal use + +export async function moveTabsBefore(tabs, referenceTab, options = {}) { + log('moveTabsBefore: ', tabs, referenceTab, options); + if (!tabs.length || + !TabsStore.ensureLivingItem(referenceTab)) + return []; + + if (referenceTab.$TST.isAllPlacedBeforeSelf(tabs)) { + log('moveTabsBefore:no need to move'); + return []; + } + return moveTabsInternallyBefore(tabs, referenceTab, options); +} +export async function moveTabBefore(tab, referenceTab, options = {}) { + return moveTabsBefore([tab], referenceTab, options).then(moved => moved.length > 0); +} + +async function moveTabsInternallyBefore(tabs, referenceTab, options = {}) { + if (!tabs.length || + !TabsStore.ensureLivingItem(referenceTab)) + return []; + + const win = TabsStore.windows.get(tabs[0].windowId); + + log('moveTabsInternallyBefore: ', tabs, `${referenceTab.id}(index=${referenceTab.index})`, options); + if (referenceTab.type == TreeItem.TYPE_GROUP) { + referenceTab = referenceTab.$TST?.firstMember; + if (!TabsStore.ensureLivingItem(referenceTab)) { + log('missing reference tab'); + return []; + } + log(` => reference tab: ${referenceTab.id}(index=${referenceTab.index})`); + } + + const precedingReferenceTab = referenceTab.$TST.previousTab; + if (referenceTab.pinned) { + // unpinned tab cannot be moved before any pinned tab + tabs = tabs.filter(tab => tab.pinned); + } + else if (precedingReferenceTab && + !precedingReferenceTab.pinned) { + // pinned tab cannot be moved after any unpinned tab + tabs = tabs.filter(tab => !tab.pinned); + } + if (!tabs.length) + return []; + + const movedTabs = []; + try { + /* + Tab elements are moved by tabs.onMoved automatically, but + the operation is asynchronous. To help synchronous operations + following to this operation, we need to move tabs immediately. + */ + const tabGroups = new Set(); + for (const tab of tabs) { + const oldPreviousTab = tab.$TST.unsafePreviousTab; + const oldNextTab = tab.$TST.unsafeNextTab; + if (oldNextTab?.id == referenceTab.id) // no move case + continue; + const fromIndex = tab.index; + if (referenceTab.index > tab.index) + tab.index = referenceTab.index - 1; + else + tab.index = referenceTab.index; + tabGroups.add(tab.$TST.nativeTabGroup); + if (SidebarConnection.isInitialized()) { // only on the background page + win.internalMovingTabs.set(tab.id, tab.index); + win.alreadyMovedTabs.set(tab.id, tab.index); + } + tab.reindexedBy = `moveTabsInternallyBefore (${tab.index})`; + Tab.track(tab); + movedTabs.push(tab); + Tab.onTabInternallyMoved.dispatch(tab, { + nextTab: referenceTab, + oldPreviousTab, + oldNextTab, + broadcasted: !!options.broadcasted + }); + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_INTERNALLY_MOVED, + windowId: tab.windowId, + tabId: tab.id, + fromIndex, + toIndex: tab.index, + nextTabId: referenceTab?.id, + broadcasted: !!options.broadcasted + }); + if (options.doNotOptimize) { + win.internalMovingTabs.set(tab.id, tab.index); + win.alreadyMovedTabs.set(tab.id, tab.index); + await browser.tabs.move(tab.id, { index: tab.index }); + win.internalMovingTabs.delete(tab.id); + win.alreadyMovedTabs.delete(tab.id); + } + } + for (const group of tabGroups) { + group?.$TST.reindex(); + } + if (movedTabs.length == 0) { + log(' => actually nothing moved'); + } + else { + log( + 'Tab nodes rearranged by moveTabsInternallyBefore:\n', + (!configs.debug ? '' : + () => toLines(Array.from(win.getOrderedTabs()), + tab => ` - ${tab.index}: ${tab.id}${tabs.includes(tab) ? '[MOVED]' : ''}`)) + ); + } + if (SidebarConnection.isInitialized()) { // only on the background page + if (options.delayedMove) { // Wait until opening animation is finished. + await wait(configs.newTabAnimationDuration); + } + if (!options.doNotOptimize) { + syncToNativeTabs(tabs); + } + } + } + catch(e) { + ApiTabs.handleMissingTabError(e); + log('moveTabsInternallyBefore failed: ', String(e)); + } + return movedTabs; +} +export async function moveTabInternallyBefore(tab, referenceTab, options = {}) { + return moveTabsInternallyBefore([tab], referenceTab, options); +} + +export async function moveTabsAfter(tabs, referenceTab, options = {}) { + log('moveTabsAfter: ', tabs, referenceTab, options); + if (!tabs.length || + !TabsStore.ensureLivingItem(referenceTab)) + return []; + + if (referenceTab.$TST.isAllPlacedAfterSelf(tabs)) { + log('moveTabsAfter:no need to move'); + return []; + } + return moveTabsInternallyAfter(tabs, referenceTab, options); +} +export async function moveTabAfter(tab, referenceTab, options = {}) { + return moveTabsAfter([tab], referenceTab, options).then(moved => moved.length > 0); +} + +async function moveTabsInternallyAfter(tabs, referenceTab, options = {}) { + if (!tabs.length || + !TabsStore.ensureLivingItem(referenceTab)) + return []; + + const win = TabsStore.windows.get(tabs[0].windowId); + + log('moveTabsInternallyAfter: ', tabs, `${referenceTab.id}(index=${referenceTab.index})`, options); + if (referenceTab.type == TreeItem.TYPE_GROUP) { + if (!referenceTab.collapsed) { + log(' => move before the first member tab of the reference group'); + return moveTabsInternallyBefore(tabs, referenceTab.$TST?.firstMember, options = {}); + } + referenceTab = referenceTab.$TST?.lastMember; + if (!TabsStore.ensureLivingItem(referenceTab)) { + log('missing reference tab'); + return []; + } + log(` => reference tab: ${referenceTab.id}(index=${referenceTab.index})`); + } + + const followingReferenceTab = referenceTab.$TST.nextTab; + if (followingReferenceTab && + followingReferenceTab.pinned) { + // unpinned tab cannot be moved before any pinned tab + tabs = tabs.filter(tab => tab.pinned); + } + else if (!referenceTab.pinned) { + // pinned tab cannot be moved after any unpinned tab + tabs = tabs.filter(tab => !tab.pinned); + } + if (!tabs.length) + return []; + + const movedTabs = []; + try { + /* + Tab elements are moved by tabs.onMoved automatically, but + the operation is asynchronous. To help synchronous operations + following to this operation, we need to move tabs immediately. + */ + let nextTab = referenceTab.$TST.unsafeNextTab; + while (nextTab && tabs.find(tab => tab.id == nextTab.id)) { + nextTab = nextTab.$TST.unsafeNextTab; + } + const tabGroups = new Set(); + for (const tab of tabs) { + const oldPreviousTab = tab.$TST.unsafePreviousTab; + const oldNextTab = tab.$TST.unsafeNextTab; + if ((!oldNextTab && !nextTab) || + (oldNextTab && nextTab && oldNextTab.id == nextTab.id)) // no move case + continue; + const fromIndex = tab.index; + if (nextTab) { + if (nextTab.index > tab.index) + tab.index = nextTab.index - 1; + else + tab.index = nextTab.index; + } + else { + tab.index = win.tabs.size - 1 + } + tabGroups.add(tab.$TST.nativeTabGroup); + if (SidebarConnection.isInitialized()) { // only on the background page + win.internalMovingTabs.set(tab.id, tab.index); + win.alreadyMovedTabs.set(tab.id, tab.index); + } + tab.reindexedBy = `moveTabsInternallyAfter (${tab.index})`; + Tab.track(tab); + movedTabs.push(tab); + Tab.onTabInternallyMoved.dispatch(tab, { + nextTab, + oldPreviousTab, + oldNextTab, + broadcasted: !!options.broadcasted + }); + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_INTERNALLY_MOVED, + windowId: tab.windowId, + tabId: tab.id, + fromIndex, + toIndex: tab.index, + nextTabId: nextTab?.id, + broadcasted: !!options.broadcasted + }); + if (options.doNotOptimize) { + win.internalMovingTabs.set(tab.id, tab.index); + win.alreadyMovedTabs.set(tab.id, tab.index); + await browser.tabs.move(tab.id, { index: tab.index }); + win.internalMovingTabs.delete(tab.id); + win.alreadyMovedTabs.delete(tab.id); + } + } + for (const group of tabGroups) { + group?.$TST.reindex(); + } + if (movedTabs.length == 0) { + log(' => actually nothing moved'); + } + else { + log( + 'Tab nodes rearranged by moveTabsInternallyAfter:\n', + (!configs.debug ? '' : + () => toLines(Array.from(win.getOrderedTabs()), + tab => ` - ${tab.index}: ${tab.id}${tabs.includes(tab) ? '[MOVED]' : ''}`)) + ); + } + if (SidebarConnection.isInitialized()) { // only on the background page + if (options.delayedMove) { // Wait until opening animation is finished. + await wait(configs.newTabAnimationDuration); + } + if (!options.doNotOptimize) { + syncToNativeTabs(tabs); + } + } + } + catch(e) { + ApiTabs.handleMissingTabError(e); + log('moveTabsInternallyAfter failed: ', String(e)); + } + return movedTabs; +} +export async function moveTabInternallyAfter(tab, referenceTab, options = {}) { + return moveTabsInternallyAfter([tab], referenceTab, options); +} + + +// ======================================================== +// Synchronize order of tab elements to browser's tabs + +const mPreviousSync = new Map(); +const mDelayedSync = new Map(); +const mDelayedSyncTimer = new Map(); + +export async function waitUntilSynchronized(windowId) { + const previous = mPreviousSync.get(windowId); + if (previous) + return previous.then(() => waitUntilSynchronized(windowId)); + return Promise.resolve(mDelayedSync.get(windowId)).then(() => { + const previous = mPreviousSync.get(windowId); + if (previous) + return waitUntilSynchronized(windowId); + }); +} + +function syncToNativeTabs(tabs) { + const windowId = tabs[0].windowId; + //log(`syncToNativeTabs(${windowId})`); + if (mDelayedSyncTimer.has(windowId)) + clearTimeout(mDelayedSyncTimer.get(windowId)); + const delayedSync = new Promise((resolve, _reject) => { + mDelayedSyncTimer.set(windowId, setTimeout(() => { + mDelayedSync.delete(windowId); + let previousSync = mPreviousSync.get(windowId); + if (previousSync) + previousSync = previousSync.then(() => syncToNativeTabsInternal(windowId)); + else + previousSync = syncToNativeTabsInternal(windowId); + previousSync = previousSync.then(resolve); + mPreviousSync.set(windowId, previousSync); + }, 250)); + }).then(() => { + mPreviousSync.delete(windowId); + }); + mDelayedSync.set(windowId, delayedSync); + return delayedSync; +} +async function syncToNativeTabsInternal(windowId) { + mDelayedSyncTimer.delete(windowId); + + if (Tab.needToWaitTracked(windowId)) + await Tab.waitUntilTrackedAll(windowId); + if (Tab.needToWaitMoved(windowId)) + await Tab.waitUntilMovedAll(windowId); + + const win = TabsStore.windows.get(windowId); + if (!win) // already destroyed + return; + + // Tabs may be removed while waiting. + const internalOrder = TabsStore.windows.get(windowId).order; + const nativeTabsOrder = (await browser.tabs.query({ windowId }).catch(ApiTabs.createErrorHandler())).map(tab => tab.id); + log(`syncToNativeTabs(${windowId}): rearrange `, { internalOrder:internalOrder.join(','), nativeTabsOrder:nativeTabsOrder.join(',') }); + + log(`syncToNativeTabs(${windowId}): step1, internalOrder => nativeTabsOrder`); + let tabIdsForUpdatedIndices = Array.from(nativeTabsOrder); + + const moveOperations = (new SequenceMatcher(nativeTabsOrder, internalOrder)).operations(); + const movedTabs = new Set(); + for (const operation of moveOperations) { + const [tag, fromStart, fromEnd, toStart, toEnd] = operation; + log(`syncToNativeTabs(${windowId}): operation `, { tag, fromStart, fromEnd, toStart, toEnd }); + switch (tag) { + case 'equal': + case 'delete': + break; + + case 'insert': + case 'replace': + let moveTabIds = internalOrder.slice(toStart, toEnd); + const referenceId = nativeTabsOrder[fromStart] || null; + let toIndex = -1; + let fromIndices = moveTabIds.map(id => tabIdsForUpdatedIndices.indexOf(id)); + if (referenceId) { + toIndex = tabIdsForUpdatedIndices.indexOf(referenceId); + } + if (toIndex < 0) + toIndex = internalOrder.length; + // ignore already removed tabs! + moveTabIds = moveTabIds.filter((id, index) => fromIndices[index] > -1); + if (moveTabIds.length == 0) + continue; + fromIndices = fromIndices.filter(index => index > -1); + const fromIndex = fromIndices[0]; + if (fromIndex < toIndex) + toIndex--; + log(`syncToNativeTabs(${windowId}): step1, move ${moveTabIds.join(',')} before ${referenceId} / from = ${fromIndex}, to = ${toIndex}`); + for (const movedId of moveTabIds) { + win.internalMovingTabs.set(movedId, -1); + win.alreadyMovedTabs.set(movedId, -1); + movedTabs.add(movedId); + } + logApiTabs(`tabs-move:syncToNativeTabs(${windowId}): step1, browser.tabs.move() `, moveTabIds, { + windowId, + index: toIndex + }); + let reallyMovedTabIds = new Set(); + try { + const reallyMovedTabs = await browser.tabs.move(moveTabIds, { + windowId, + index: toIndex + }).catch(ApiTabs.createErrorHandler(e => { + log(`syncToNativeTabs(${windowId}): step1, failed to move: `, String(e), e.stack); + throw e; + })); + reallyMovedTabIds = new Set(reallyMovedTabs.map(tab => tab.id)); + } + catch(error) { + console.error(error); + } + for (const id of moveTabIds) { + if (reallyMovedTabIds.has(id)) + continue; + log(`syncToNativeTabs(${windowId}): failed to move tab ${id}: maybe unplacable position (regular tabs in pinned tabs/pinned tabs in regular tabs), or any other reason`); + win.internalMovingTabs.delete(id); + win.alreadyMovedTabs.delete(id); + } + tabIdsForUpdatedIndices = tabIdsForUpdatedIndices.filter(id => !moveTabIds.includes(id)); + tabIdsForUpdatedIndices.splice(toIndex, 0, ...moveTabIds); + break; + } + } + log(`syncToNativeTabs(${windowId}): step1, rearrange completed.`); + + if (movedTabs.size > 0) { + // tabs.onMoved produced by this operation can break the order of tabs + // in the sidebar, so we need to synchronize complete order of tabs after + // all. + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_SYNC_TABS_ORDER, + windowId + }); + + // Multiple times asynchronous tab move is unstable, so we retry again + // for safety until all tabs are completely synchronized. + syncToNativeTabs([{ windowId }]); + } +} diff --git a/waterfox/browser/components/sidebar/background/tabs-open.js b/waterfox/browser/components/sidebar/background/tabs-open.js new file mode 100644 index 000000000000..f1daf16d519a --- /dev/null +++ b/waterfox/browser/components/sidebar/background/tabs-open.js @@ -0,0 +1,342 @@ +/* ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is the Tree Style Tab. + * + * The Initial Developer of the Original Code is YUKI "Piro" Hiroshi. + * Portions created by the Initial Developer are Copyright (C) 2011-2024 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): YUKI "Piro" Hiroshi + * wanabe + * Tetsuharu OHZEKI + * Xidorn Quan (Firefox 40+ support) + * lv7777 (https://github.com/lv7777) + * + * ***** END LICENSE BLOCK ******/ +'use strict'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; + +import { + log as internalLogger, + configs, + tryRevokeObjectURL, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from '/common/constants.js'; +import * as SidebarConnection from '/common/sidebar-connection.js'; +import * as TabsStore from '/common/tabs-store.js'; + +import { Tab } from '/common/TreeItem.js'; + +import * as TabsMove from './tabs-move.js'; +import * as Tree from './tree.js'; + +export const onForbiddenURLRequested = new EventListenerManager(); + +function log(...args) { + internalLogger('background/tabs-open', ...args); +} + +const SEARCH_PREFIX_MATCHER = /^(ext\+ws:search:|about:ws-search\?)/; + +export async function loadURI(uri, options = {}) { + if (!options.windowId && !options.tab) + throw new Error('missing loading target window or tab'); + try { + let tabId; + if (options.tab) { + tabId = options.tab.id; + } + else { + let tabs = await browser.tabs.query({ + windowId: options.windowId, + active: true + }).catch(ApiTabs.createErrorHandler()); + if (tabs.length == 0) + tabs = await browser.tabs.query({ + windowId: options.windowId, + }).catch(ApiTabs.createErrorHandler()); + tabId = tabs[0].id; + } + let searchQuery = null; + if (SEARCH_PREFIX_MATCHER.test(uri)) { + const query = uri.replace(SEARCH_PREFIX_MATCHER, ''); + if (browser.search && + typeof browser.search.search == 'function') + searchQuery = query; + else + uri = configs.defaultSearchEngine.replace(/%s/gi, query); + } + if (searchQuery) { + await browser.search.search({ + query: searchQuery, + tabId + }).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } + else { + await browser.tabs.update(tabId, { + url: sanitizeURL(uri), + }).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } + } + catch(e) { + ApiTabs.handleMissingTabError(e); + } +} + +export async function openNewTab(options = {}) { + const win = TabsStore.windows.get(options.windowId); + win.toBeOpenedNewTabCommandTab++; + return openURIInTab(options.url || options.uri || null, options); +} + +export async function openURIInTab(uri, options = {}) { + const tabs = await openURIsInTabs([uri], options); + return tabs[0]; +} + +const FORBIDDEN_URL_MATCHER = /^(about|chrome|resource|file):/; +const ALLOWED_URL_MATCHER = /^about:blank(\?|$)/; + +export async function openURIsInTabs(uris, { windowId, insertBefore, insertAfter, cookieStoreId, isOrphan, active, inBackground, discarded, opener, parent, fixPositions } = {}) { + log('openURIsInTabs: ', { uris, windowId, insertBefore, insertAfter, cookieStoreId, isOrphan, active, inBackground, discarded, opener, parent, fixPositions }); + if (!windowId) + throw new Error('missing loading target window\n' + new Error().stack); + + const tabs = []; + // Don't return the result of Tab.doAndGetNewTabs because their order can + // be inverted due to browser.tabs.insertAfterCurrent=true + const actuallyOpenedTabIds = new Set(await Tab.doAndGetNewTabs(async () => { + await Tab.waitUntilTrackedAll(windowId); + await TabsMove.waitUntilSynchronized(windowId); + const startIndex = Tab.calculateNewTabIndex({ insertAfter, insertBefore }); + log('startIndex: ', startIndex); + const win = TabsStore.windows.get(windowId); + if (insertBefore || + insertAfter || + uris.some(uri => uri && typeof uri == 'object' && 'index' in uri)) + win.toBeOpenedTabsWithPositions += uris.length; + if (cookieStoreId) + win.toBeOpenedTabsWithCookieStoreId += uris.length; + if (isOrphan) + win.toBeOpenedOrphanTabs += uris.length; + return Promise.all(uris.map(async (uri, index) => { + const params = { + windowId, + active: index == 0 && (active || (inBackground === false)), + }; + if (uri && typeof uri == 'object') { // tabs.create() compatible + if ('active' in uri) + params.active = uri.active; + if ('cookieStoreId' in uri) + params.cookieStoreId = uri.cookieStoreId; + if ('discarded' in uri) + params.discarded = uri.discarded; + if ('index' in uri) + params.index = uri.index; + if ('openerTabId' in uri) + params.openerTabId = uri.openerTabId; + if ('openInReaderMode' in uri) + params.openInReaderMode = uri.openInReaderMode; + if ('pinned' in uri) + params.pinned = uri.pinned; + if ('selected' in uri) + params.active = uri.selected; + if ('title' in uri) + params.title = uri.title; + uri = uri.uri || uri.url; + } + let searchQuery = null; + if (uri) { + if (SEARCH_PREFIX_MATCHER.test(uri)) { + const query = uri.replace(SEARCH_PREFIX_MATCHER, ''); + if (browser.search && + typeof browser.search.search == 'function') + searchQuery = query; + else + params.url = configs.defaultSearchEngine.replace(/%s/gi, query); + } + else { + params.url = uri; + } + } + if (discarded && + !params.active && + !('discarded' in params)) + params.discarded = true; + if (params.url == 'about:newtab') + delete params.url + if (params.url) + params.url = sanitizeURL(params.url); + if (!('url' in params /* about:newtab case */) || + /^about:/.test(params.url)) + params.discarded = false; // discarded tab cannot be opened with any about: URL + if (!params.discarded) // title cannot be set for non-discarded tabs + params.title = null; + if (opener && !params.openerTabId) + params.openerTabId = opener.id; + if (startIndex > -1 && !('index' in params)) + params.index = startIndex + index; + if (cookieStoreId && !params.cookieStoreId) + params.cookieStoreId = cookieStoreId; + // Tabs opened with different container can take time to be tracked, + // then TabsStore.waitUntilTabsAreCreated() may be resolved before it is + // tracked like as "the tab is already closed". So we wait until the + // tab is correctly tracked. + const promisedNewTabTracked = new Promise((resolve, reject) => { + const listener = (tab) => { + Tab.onCreating.removeListener(listener); + browser.tabs.get(tab.id) + .then(resolve) + .catch(ApiTabs.createErrorSuppressor(reject)); + }; + Tab.onCreating.addListener(listener); + }); + const createdTab = await browser.tabs.create(params).catch(ApiTabs.createErrorHandler()); + await Promise.all([ + promisedNewTabTracked, // TabsStore.waitUntilTabsAreCreated(createdTab.id), + searchQuery && browser.search.search({ + query: searchQuery, + tabId: createdTab.id + }).catch(ApiTabs.createErrorHandler()) + ]); + const tab = Tab.get(createdTab.id); + log('created tab: ', tab); + if (!tab) + throw new Error('tab is already closed'); + if (!opener && + parent && + !isOrphan) + await Tree.attachTabTo(tab, parent, { + insertBefore: insertBefore, + insertAfter: insertAfter, + forceExpand: params.active, + broadcast: true + }); + else if (insertBefore) + await TabsMove.moveTabInternallyBefore(tab, insertBefore, { + broadcast: true + }); + else if (insertAfter) + await TabsMove.moveTabInternallyAfter(tab, insertAfter, { + broadcast: true + }); + log('tab is opened.'); + await tab.$TST.opened; + tabs.push(tab); + tryRevokeObjectURL(tab.url); + return tab; + })); + }, windowId)); + const openedTabs = tabs.filter(tab => actuallyOpenedTabIds.has(tab)); + + if (fixPositions && + openedTabs.every((tab, index) => (index == 0) || (openedTabs[index-1].index - tab.index) == 1)) { + // tabs are opened with reversed order due to browser.tabs.insertAfterCurrent=true + let lastTab; + for (const tab of openedTabs.slice(0).reverse()) { + if (lastTab) + TabsMove.moveTabInternallyBefore(tab, lastTab); + lastTab = tab; + } + await TabsMove.waitUntilSynchronized(windowId); + } + return openedTabs; +} + +function sanitizeURL(url) { + if (ALLOWED_URL_MATCHER.test(url)) + return url; + + // tabs.create() doesn't accept about:reader URLs so we fallback them to regular URLs. + if (/^about:reader\?/.test(url)) + return (new URL(url)).searchParams.get('url') || 'about:blank'; + + if (FORBIDDEN_URL_MATCHER.test(url)) { + onForbiddenURLRequested.dispatch(url); + return `about:blank?forbidden-url=${url}`; + } + + return url; +} + +export function isOpenable(url) { + return !url || url == sanitizeURL(url); +} + + +function onMessage(message, openerTab) { + switch (message.type) { + case Constants.kCOMMAND_LOAD_URI: + loadURI(message.uri, { + tab: Tab.get(message.tabId) + }); + break; + + case Constants.kCOMMAND_OPEN_TAB: + if (!message.parentId && openerTab) + message.parentId = openerTab.id; + if (!message.windowId && openerTab) + message.windowId = openerTab.windowId; + Tab.waitUntilTracked([ + message.parentId, + message.insertBeforeId, + message.insertAfterId + ]).then(() => { + openURIsInTabs(message.uris || [message.uri], { + windowId: message.windowId, + parent: Tab.get(message.parentId), + insertBefore: Tab.get(message.insertBeforeId), + insertAfter: Tab.get(message.insertAfterId), + active: !!message.active, + discarded: message.discarded, + }); + }); + break; + + case Constants.kCOMMAND_NEW_TABS: + Tab.waitUntilTracked([ + message.openerId, + message.parentId, + message.insertBeforeId, + message.insertAfterId + ]).then(() => { + log('new tabs requested: ', message); + openURIsInTabs(message.uris, { + windowId: message.windowId, + opener: Tab.get(message.openerId), + parent: Tab.get(message.parentId), + insertBefore: Tab.get(message.insertBeforeId), + insertAfter: Tab.get(message.insertAfterId), + active: message.active, + discarded: message.discarded, + }); + }); + break; + } +} + +SidebarConnection.onMessage.addListener((windowId, message) => { + onMessage(message); +}); + +browser.runtime.onMessage.addListener((message, sender) => { + if (!message || + typeof message.type != 'string' || + message.type.indexOf('ws:') != 0) + return; + + onMessage(message, sender.tab); +}); diff --git a/waterfox/browser/components/sidebar/background/tree-structure.js b/waterfox/browser/components/sidebar/background/tree-structure.js new file mode 100644 index 000000000000..f9f7d8fc413f --- /dev/null +++ b/waterfox/browser/components/sidebar/background/tree-structure.js @@ -0,0 +1,691 @@ +/* +# 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'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; + +import { + log as internalLogger, + dumpTab, + toLines, + configs, + wait, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from '/common/constants.js'; +import * as SidebarConnection from '/common/sidebar-connection.js'; +import * as TabsInternalOperation from '/common/tabs-internal-operation.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TreeBehavior from '/common/tree-behavior.js'; +import * as UserOperationBlocker from '/common/user-operation-blocker.js'; + +import MetricsData from '/common/MetricsData.js'; +import { Tab } from '/common/TreeItem.js'; + +import * as Commands from './commands.js'; +import * as TabsMove from './tabs-move.js'; +import * as TabsOpen from './tabs-open.js'; +import * as Tree from './tree.js'; + +function log(...args) { + internalLogger('background/tree-structure', ...args); +} + +export const onTabAttachedFromRestoredInfo = new EventListenerManager(); + +let mRecentlyClosedTabs = []; +let mRecentlyClosedTabsTreeStructure = []; + +export function startTracking() { + Tab.onCreated.addListener((tab, _info) => { reserveToSaveTreeStructure(tab.windowId); }); + Tab.onRemoved.addListener((tab, info) => { + if (!info.isWindowClosing) + reserveToSaveTreeStructure(tab.windowId); + }); + Tab.onMoved.addListener((tab, _info) => { reserveToSaveTreeStructure(tab.windowId); }); + Tab.onUpdated.addListener((tab, info) => { + if ('openerTabId' in info) + reserveToSaveTreeStructure(tab.windowId); + }); + Tree.onAttached.addListener((tab, _info) => { reserveToSaveTreeStructure(tab.windowId); }); + Tree.onDetached.addListener((tab, _info) => { reserveToSaveTreeStructure(tab.windowId); }); + Tree.onSubtreeCollapsedStateChanging.addListener(tab => { reserveToSaveTreeStructure(tab.windowId); }); +} + +export function reserveToSaveTreeStructure(windowId) { + const win = TabsStore.windows.get(windowId); + if (!win) + return; + + if (win.waitingToSaveTreeStructure) + clearTimeout(win.waitingToSaveTreeStructure); + win.waitingToSaveTreeStructure = setTimeout(() => { + saveTreeStructure(windowId); + }, 150); +} +async function saveTreeStructure(windowId) { + const win = TabsStore.windows.get(windowId); + if (!win) + return; + + const structure = TreeBehavior.getTreeStructureFromTabs(Tab.getAllTabs(windowId)); + browser.sessions.setWindowValue( + windowId, + Constants.kWINDOW_STATE_TREE_STRUCTURE, + structure + ).catch(ApiTabs.createErrorSuppressor()); +} + +export async function loadTreeStructure(windows, restoredFromCacheResults) { + log('loadTreeStructure'); + MetricsData.add('loadTreeStructure: start'); + return MetricsData.addAsync('loadTreeStructure: restoration for windows', Promise.all(windows.map(async win => { + if (restoredFromCacheResults && + restoredFromCacheResults.get(win.id)) { + log(`skip tree structure restoration for window ${win.id} (restored from cache)`); + return; + } + const tabs = Tab.getAllTabs(win.id); + let windowStateCompletelyApplied = false; + try { + const structure = await browser.sessions.getWindowValue(win.id, Constants.kWINDOW_STATE_TREE_STRUCTURE).catch(ApiTabs.createErrorHandler()); + let uniqueIds = tabs.map(tab => tab.$TST.uniqueId || '?'); + MetricsData.add('loadTreeStructure: read stored data'); + if (structure && + structure.length > 0 && + structure.length <= tabs.length) { + uniqueIds = uniqueIds.map(id => id.id); + let tabsOffset; + if (structure[0].id) { + const structureSignature = toLines(structure, item => item.id); + tabsOffset = uniqueIds.join('\n').indexOf(structureSignature); + windowStateCompletelyApplied = tabsOffset > -1; + } + else { + tabsOffset = 0; + windowStateCompletelyApplied = structure.length == tabs.length; + } + if (tabsOffset > -1) { + const structureRestoreTabs = tabs.slice(tabsOffset); + await Tree.applyTreeStructureToTabs(structureRestoreTabs, structure); + for (const tab of structureRestoreTabs) { + tab.$TST.temporaryMetadata.set('treeStructureAlreadyRestoredFromSessionData', true); + } + MetricsData.add('loadTreeStructure: Tree.applyTreeStructureToTabs'); + } + else { + MetricsData.add('loadTreeStructure: mismatched signature'); + } + } + else { + MetricsData.add('loadTreeStructure: no valid structure information'); + } + } + catch(error) { + console.log(`TreeStructure.loadTreeStructure: Fatal error, ${error}`, error.stack); + MetricsData.add('loadTreeStructure: failed to apply tree structure'); + } + if (!windowStateCompletelyApplied) { + log(`Tree information for the window ${win.id} is not same to actual state. Fallback to restoration from tab relations.`); + MetricsData.add('loadTreeStructure: fallback to reserveToAttachTabFromRestoredInfo'); + const unattachedTabs = new Set(tabs); + for (const tab of tabs) { + reserveToAttachTabFromRestoredInfo(tab, { + keepCurrentTree: true, + canCollapse: true + }).then(attached => { + if (attached || + tab.$TST.parent || + tab.$TST.hasChild) + unattachedTabs.delete(tab); + }); + } + await reserveToAttachTabFromRestoredInfo.promisedDone; + MetricsData.add('loadTreeStructure: attachTabFromRestoredInfo finish'); + + // unknown tabs may appear inside tree, so we need to fixup tree based on their position. + for (const tab of unattachedTabs) { + const action = Tree.detectTabActionFromNewPosition(tab, { + fromIndex: tabs.length - 1, + toIndex: tab.index, + isTabCreating: true, + }); + switch (action.action) { + default: + break; + + case 'attach': + case 'detach': + log('loadTreeStructure: apply action for unattached tab: ', tab, action); + await action.applyIfNeeded(); + break; + } + } + MetricsData.add('loadTreeStructure: finish to fixup tree structure'); + } + Tab.dumpAll(); + }))); +} + +async function reserveToAttachTabFromRestoredInfo(tab, options = {}) { + if (reserveToAttachTabFromRestoredInfo.waiting) + clearTimeout(reserveToAttachTabFromRestoredInfo.waiting); + reserveToAttachTabFromRestoredInfo.tasks.push({ tab, options: options }); + if (!reserveToAttachTabFromRestoredInfo.promisedDone) { + reserveToAttachTabFromRestoredInfo.promisedDone = new Promise((resolve, _reject) => { + reserveToAttachTabFromRestoredInfo.onDone = resolve; + }); + } + reserveToAttachTabFromRestoredInfo.waiting = setTimeout(async () => { + reserveToAttachTabFromRestoredInfo.waiting = null; + const tasks = reserveToAttachTabFromRestoredInfo.tasks.slice(0); + reserveToAttachTabFromRestoredInfo.tasks = []; + const uniqueIds = tasks.map(task => task.tab.$TST.uniqueId); + const bulk = tasks.length > 1; + const attachedResults = await Promise.all(uniqueIds.map((uniqueId, index) => { + const task = tasks[index]; + return attachTabFromRestoredInfo(task.tab, { + ...task.options, + uniqueId, + bulk + }).catch(error => { + console.log(`TreeStructure.reserveToAttachTabFromRestoredInfo: Fatal error on processing task ${index}, ${error}`, error.stack); + return false; + }); + })); + if (typeof reserveToAttachTabFromRestoredInfo.onDone == 'function') + reserveToAttachTabFromRestoredInfo.onDone(attachedResults.every(attached => !!attached)); + delete reserveToAttachTabFromRestoredInfo.onDone; + delete reserveToAttachTabFromRestoredInfo.promisedDone; + Tab.dumpAll(); + }, 100); + return reserveToAttachTabFromRestoredInfo.promisedDone; +} +reserveToAttachTabFromRestoredInfo.waiting = null; +reserveToAttachTabFromRestoredInfo.tasks = []; +reserveToAttachTabFromRestoredInfo.promisedDone = null; + + +async function attachTabFromRestoredInfo(tab, options = {}) { + log('attachTabFromRestoredInfo ', tab, options); + if (tab.$TST.temporaryMetadata.has('treeStructureAlreadyRestoredFromSessionData')) { + log(' => already restored ', tab.id); + return; + } + + let uniqueId, insertBefore, insertAfter, insertAfterLegacy, ancestors, children, states, collapsed /* for backward compatibility */; + // eslint-disable-next-line prefer-const + [uniqueId, insertBefore, insertAfter, insertAfterLegacy, ancestors, children, states, collapsed] = await Promise.all([ + options.uniqueId || tab.$TST.uniqueId || tab.$TST.promisedUniqueId, + browser.sessions.getTabValue(tab.id, Constants.kPERSISTENT_INSERT_BEFORE).catch(ApiTabs.createErrorHandler()), + browser.sessions.getTabValue(tab.id, Constants.kPERSISTENT_INSERT_AFTER).catch(ApiTabs.createErrorHandler()), + // This legacy should be removed after legacy data are cleared enough, maybe after Firefox 128 is released. + browser.sessions.getTabValue(tab.id, Constants.kPERSISTENT_INSERT_AFTER_LEGACY).catch(ApiTabs.createErrorHandler()), + browser.sessions.getTabValue(tab.id, Constants.kPERSISTENT_ANCESTORS).catch(ApiTabs.createErrorHandler()), + browser.sessions.getTabValue(tab.id, Constants.kPERSISTENT_CHILDREN).catch(ApiTabs.createErrorHandler()), + tab.$TST.getPermanentStates(), + browser.sessions.getTabValue(tab.id, Constants.kPERSISTENT_SUBTREE_COLLAPSED).catch(ApiTabs.createErrorHandler()) // for backward compatibility + ]); + ancestors = ancestors || []; + children = children || []; + log(`persistent references for ${dumpTab(tab)} (${uniqueId.id}): `, { + insertBefore, insertAfter, + insertAfterLegacy, + ancestors: ancestors.join(', '), + children: children.join(', '), + states, + collapsed + }); + if (collapsed && !states.includes(Constants.kTAB_STATE_SUBTREE_COLLAPSED)) { + // migration + states.push(Constants.kTAB_STATE_SUBTREE_COLLAPSED); + browser.sessions.removeTabValue(tab.id, Constants.kPERSISTENT_SUBTREE_COLLAPSED).catch(ApiTabs.createErrorSuppressor()); + } + insertBefore = Tab.getByUniqueId(insertBefore); + insertAfter = Tab.getByUniqueId(insertAfter || insertAfterLegacy); + ancestors = ancestors.map(Tab.getByUniqueId); + children = children.map(Tab.getByUniqueId); + log(' => references: ', tab.id, () => ({ + insertBefore: dumpTab(insertBefore), + insertAfter: dumpTab(insertAfter), + ancestors: ancestors.map(dumpTab).join(', '), + children: children.map(dumpTab).join(', ') + })); + if (configs.fixupTreeOnTabVisibilityChanged) { + ancestors = ancestors.filter(ancestor => ancestor && (ancestor.hidden == tab.hidden)); + children = children.filter(child => child && (child.hidden == tab.hidden)); + log(' ==> references: ', tab.id, () => ({ + ancestors: ancestors.map(dumpTab).join(', '), + children: children.map(dumpTab).join(', ') + })); + } + + // clear wrong positioning information + if (tab.pinned || + insertBefore?.pinned) + insertBefore = null; + const nextOfInsertAfter = insertAfter?.$TST.nextTab; + if (nextOfInsertAfter && + nextOfInsertAfter.pinned) + insertAfter = null; + + let attached = false; + const active = tab.active; + const promises = []; + for (const ancestor of ancestors) { + if (!ancestor) + continue; + log(' attach to old ancestor: ', tab.id, { child: tab, parent: ancestor }); + const promisedDone = Tree.attachTabTo(tab, ancestor, { + insertBefore, + insertAfter, + dontExpand: !active, + forceExpand: active, + broadcast: true + }); + if (options.bulk) + promises.push(promisedDone); + else + await promisedDone; + attached = true; + break; + } + if (!attached) { + const opener = tab.$TST.openerTab; + if (opener && + configs.syncParentTabAndOpenerTab) { + log(' attach to opener: ', tab.id, { child: tab, parent: opener }); + const promisedDone = Tree.attachTabTo(tab, opener, { + dontExpand: !active, + forceExpand: active, + broadcast: true, + insertAt: Constants.kINSERT_NEAREST + }); + if (options.bulk) + promises.push(promisedDone); + else + await promisedDone; + } + else if (!options.bulk && + (tab.$TST.nearestCompletelyOpenedNormalFollowingTab || + tab.$TST.nearestCompletelyOpenedNormalPrecedingTab)) { + log(' attach from position: ', tab.id); + onTabAttachedFromRestoredInfo.dispatch(tab, { + toIndex: tab.index, + fromIndex: Tab.getLastTab(tab.windowId).index + }); + } + } + if (!options.keepCurrentTree && + // the restored tab is a roo tab + ancestors.length == 0 && + // but attached to any parent based on its restored position + tab.$TST.parent && + // when not in-middle position of existing tree (safely detachable position) + !tab.$TST.nextSiblingTab) { + Tree.detachTab(tab, { + broadcast: true + }); + } + if (options.children && !options.bulk) { + let firstInTree = tab.$TST.firstChild || tab; + let lastInTree = tab.$TST.lastDescendant || tab; + for (const child of children) { + if (!child) + continue; + await Tree.attachTabTo(child, tab, { + dontExpand: !child.active, + forceExpand: active, + insertAt: Constants.kINSERT_NEAREST, + dontMove: child.index >= firstInTree.index && child.index <= lastInTree.index + 1, + broadcast: true + }); + if (child.index < firstInTree.index) + firstInTree = child; + else if (child.index > lastInTree.index) + lastInTree = child; + } + } + + const subtreeCollapsed = states.includes(Constants.kTAB_STATE_SUBTREE_COLLAPSED); + log('restore subtree collapsed state: ', tab.id, { current: tab.$TST.subtreeCollapsed, expected: subtreeCollapsed, ...options }); + if ((options.canCollapse || options.bulk) && + tab.$TST.subtreeCollapsed != subtreeCollapsed) { + const promisedDone = Tree.collapseExpandSubtree(tab, { + broadcast: true, + collapsed: subtreeCollapsed, + justNow: true + }); + promises.push(promisedDone); + } + + const updateCollapsedState = () => { + const shouldBeCollapsed = tab.$TST.ancestors.some(ancestor => ancestor.$TST.collapsed || ancestor.$TST.subtreeCollapsed); + log('update collapsed state: ', tab.id, { current: tab.$TST.collapsed, expected: shouldBeCollapsed }); + if ((options.canCollapse || options.bulk) && + tab.$TST.collapsed != shouldBeCollapsed) { + Tree.collapseExpandTabAndSubtree(tab, { + broadcast: true, + collapsed: !tab.$TST.collapsed, + justNow: true + }); + } + }; + + tab.$TST.temporaryMetadata.set('treeStructureAlreadyRestoredFromSessionData', true); + + if (options.bulk) + await Promise.all(promises).then(updateCollapsedState); + else + updateCollapsedState(); + + return attached; +} + +const mRestoringTabs = new Map(); +const mMaxRestoringTabs = new Map(); +const mRestoredTabIds = new Set(); +const mProcessingTabRestorations = []; + +Tab.onRestored.addListener(tab => { + log('onTabRestored ', dumpTab(tab)); + mProcessingTabRestorations.push(async () => { + try { + const count = mRestoringTabs.get(tab.windowId) || 0; + if (count == 0) { + setTimeout(() => { + const count = mRestoringTabs.get(tab.windowId) || 0; + if (count > 0) { + UserOperationBlocker.blockIn(tab.windowId, { throbber: true }); + UserOperationBlocker.setProgress(0, tab.windowId); + } + }, configs.delayToBlockUserOperationForTabsRestoration); + } + mRestoringTabs.set(tab.windowId, count + 1); + const maxCount = mMaxRestoringTabs.get(tab.windowId) || 0; + mMaxRestoringTabs.set(tab.windowId, Math.max(count, maxCount)); + + const uniqueId = await tab.$TST.promisedUniqueId; + mRestoredTabIds.add(uniqueId.id); + + if (count == 0) { + // Force restore recycled active tab. + // See also: https://github.com/piroor/treestyletab/issues/2191#issuecomment-489271889 + const activeTab = Tab.getActiveTab(tab.windowId); + const [uniqueId, restoredUniqueId] = await Promise.all([ + activeTab.$TST.promisedUniqueId, + browser.sessions.getTabValue(activeTab.id, Constants.kPERSISTENT_ID).catch(ApiTabs.createErrorHandler()) + ]); + if (restoredUniqueId?.id != uniqueId.id) { + activeTab.$TST.updateUniqueId({ id: restoredUniqueId.id }); + reserveToAttachTabFromRestoredInfo(activeTab, { + children: true + }); + } + } + + reserveToAttachTabFromRestoredInfo(tab, { + children: true + }); + + reserveToAttachTabFromRestoredInfo.promisedDone.then(() => { + Tree.fixupSubtreeCollapsedState(tab, { + justNow: true, + broadcast: true + }); + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_RESTORED, + tabId: tab.id, + windowId: tab.windowId + }); + + let count = mRestoringTabs.get(tab.windowId) || 0; + count--; + if (count == 0) { + mRestoringTabs.delete(tab.windowId); + mMaxRestoringTabs.delete(tab.windowId); + setTimeout(() => { // because window.requestAnimationFrame is decelerate for an invisible document. + // unblock in the next event loop, after other asynchronous operations are finished + UserOperationBlocker.unblockIn(tab.windowId, { throbber: true }); + }, 0); + + const countToBeRestored = mRecentlyClosedTabs.filter(tab => !mRestoredTabIds.has(tab.uniqueId)); + log('countToBeRestored: ', countToBeRestored); + if (countToBeRestored > 0) + tryRestoreClosedSetFor(tab, countToBeRestored); + mRestoredTabIds.clear(); + } + else { + mRestoringTabs.set(tab.windowId, count); + const maxCount = mMaxRestoringTabs.get(tab.windowId); + UserOperationBlocker.setProgress(Math.round(maxCount - count / maxCount * 100), tab.windowId); + } + }); + } + catch(_e) { + } + mProcessingTabRestorations.shift(); + if (mProcessingTabRestorations.length > 0) + mProcessingTabRestorations[0](); + }); + if (mProcessingTabRestorations.length == 1) + mProcessingTabRestorations[0](); +}); + + +// Implementation for the "Undo Close Tab*s*" feature +// https://github.com/piroor/treestyletab/issues/2627 + +const mPendingRecentlyClosedTabsInfo = { + tabs: [], + structure: [] +}; + +Tab.onRemoved.addListener((_tab, _info) => { + const currentlyRestorable = mRecentlyClosedTabs.length > 1; + + mRecentlyClosedTabs = []; + mRecentlyClosedTabsTreeStructure = []; + + const newlyRestorable = mRecentlyClosedTabs.length > 1; + if (currentlyRestorable != newlyRestorable) + Tab.onChangeMultipleTabsRestorability.dispatch(newlyRestorable); +}); + +Tab.onMultipleTabsRemoving.addListener((tabs, { triggerTab, originalStructure } = {}) => { + if (triggerTab) + tabs = [triggerTab, ...tabs]; + mPendingRecentlyClosedTabsInfo.tabs = tabs.map(tab => ({ + originalId: tab.id, + uniqueId: tab.$TST.uniqueId.id, + windowId: tab.windowId, + title: tab.title, + url: tab.url, + cookieStoreId: tab.cookieStoreId + })); + mPendingRecentlyClosedTabsInfo.structure = originalStructure || TreeBehavior.getTreeStructureFromTabs(tabs, { + full: true, + keepParentOfRootTabs: true + }); + log('mPendingRecentlyClosedTabsInfo.tabs = ', mPendingRecentlyClosedTabsInfo.tabs); + log('mPendingRecentlyClosedTabsInfo.structure = ', mPendingRecentlyClosedTabsInfo.structure); +}); + +Tab.onMultipleTabsRemoved.addListener((tabs, { triggerTab } = {}) => { + log('multiple tabs are removed'); + const currentlyRestorable = mRecentlyClosedTabs.length > 1; + + if (triggerTab) + tabs = [triggerTab, ...tabs]; + const tabIds = new Set(tabs.map(tab => tab.id)); + mRecentlyClosedTabs = mPendingRecentlyClosedTabsInfo.tabs.filter(info => tabIds.has(info.originalId)); + mRecentlyClosedTabsTreeStructure = mPendingRecentlyClosedTabsInfo.structure.filter(structure => tabIds.has(structure.originalId)); + log(' structure: ', mRecentlyClosedTabsTreeStructure); + + const newlyRestorable = mRecentlyClosedTabs.length > 1; + if (currentlyRestorable != newlyRestorable) + Tab.onChangeMultipleTabsRestorability.dispatch(newlyRestorable); + + mPendingRecentlyClosedTabsInfo.tabs = []; + mPendingRecentlyClosedTabsInfo.structure = []; +}); + +let mToBeActivatedRestoredTabId = null; + +function onRestoredTabActivated(activeInfo) { + if (mToBeActivatedRestoredTabId && + activeInfo.id != mToBeActivatedRestoredTabId) { + TabsInternalOperation.activateTab(mToBeActivatedRestoredTabId); + } + mToBeActivatedRestoredTabId = null; +} + +browser.tabs.onActivated.addListener(onRestoredTabActivated); + +async function tryRestoreClosedSetFor(tab, countToBeRestored) { + const lastRecentlyClosedTabs = mRecentlyClosedTabs; + const lastRecentlyClosedTabsTreeStructure = mRecentlyClosedTabsTreeStructure; + mRecentlyClosedTabs = []; + mRecentlyClosedTabsTreeStructure = []; + if (lastRecentlyClosedTabs.length > 1) + Tab.onChangeMultipleTabsRestorability.dispatch(false); + + if (!configs.undoMultipleTabsClose) + return; + + const alreadRestoredIndex = lastRecentlyClosedTabs.findIndex(info => info.uniqueId == tab.$TST.uniqueId.id && info.windowId == tab.windowId); + log('tryRestoreClosedSetFor ', tab, lastRecentlyClosedTabs, lastRecentlyClosedTabsTreeStructure); + if (alreadRestoredIndex < 0) { + log(' => not a member of restorable tab set.'); + return; + } + + const toBeRestoredTabsCount = Math.min( + typeof countToBeRestored == 'number' ? countToBeRestored : Number.MAX_SAFE_INTEGER, + lastRecentlyClosedTabs.filter(tabInfo => tabInfo.uniqueId != tab.$TST.uniqueId.id).length + ); + if (toBeRestoredTabsCount == 0) { + log(' => no more tab to be restored.'); + return; + } + + const sessions = (await browser.sessions.getRecentlyClosed({ + maxResults: browser.sessions.MAX_SESSION_RESULTS + }).catch(ApiTabs.createErrorHandler())).filter(session => session.tab); + + const canRestoreWithSession = toBeRestoredTabsCount <= sessions.length; + + let restoredTabs; + if (canRestoreWithSession) { + log(`tryRestoreClosedSetFor: restore ${toBeRestoredTabsCount} tabs with the sessions API`); + const unsortedRestoredTabs = await Commands.restoreTabs(toBeRestoredTabsCount); + unsortedRestoredTabs.push(tab); + // tabs can be restored in different order, then we need to rearrange them manually + const tabsByUniqueId = new Map(); + for (const tab of unsortedRestoredTabs) { + tabsByUniqueId.set(tab.$TST.uniqueId.id, tab); + } + restoredTabs = await Promise.all(lastRecentlyClosedTabsTreeStructure.map(tabInfo => { + const restoredTab = tabsByUniqueId.get(tabInfo.id); + if (restoredTab) + return restoredTab; + log('tryRestoreClosedSetFor: recreate tab for ', tabInfo); + return TabsOpen.openURIInTab({ + title: tabInfo.title, + url: tabInfo.url, + cookieStoreId: tabInfo.cookieStoreId + }, { + windowId: tab.windowId, + isOrphan: true, + inBackground: true, + discarded: true, + fixPositions: true + }); + })); + let lastTab; + for (const tab of restoredTabs) { + if (lastTab && tab.$TST.previousTab != lastTab) + await TabsMove.moveTabAfter( + tab, + lastTab, + { broadcast: true } + ); + lastTab = tab; + } + } + else { + log('tryRestoreClosedSetFor: recreate tabs'); + const tabOpenOptions = { + windowId: lastRecentlyClosedTabs[0].windowId, + isOrphan: true, + inBackground: true, + discarded: true, + fixPositions: true + }; + const beforeTabs = await TabsOpen.openURIsInTabs( + lastRecentlyClosedTabs.slice(0, alreadRestoredIndex).map(info => ({ + title: info.title, + url: info.url, + cookieStoreId: info.cookieStoreId + })), + tabOpenOptions + ); + const afterTabs = await TabsOpen.openURIsInTabs( + lastRecentlyClosedTabs.slice(alreadRestoredIndex + 1).map(info => ({ + title: info.title, + url: info.url, + cookieStoreId: info.cookieStoreId + })), + tabOpenOptions + ); + // We need to move tabs after they are opened instead of + // specifying the "index" option for TabsOpen.openURIsInTabs(), + // because the given restored tab can be moved while this + // operation. + if (beforeTabs.length > 0) + await TabsMove.moveTabsBefore( + beforeTabs, + tab, + { broadcast: true } + ); + if (afterTabs.length > 0) + await TabsMove.moveTabsAfter( + afterTabs, + tab, + { broadcast: true } + ); + restoredTabs = [...beforeTabs, tab, ...afterTabs]; + await TabsInternalOperation.activateTab(restoredTabs[0]); + } + + const rootTabs = restoredTabs.filter((tab, index) => lastRecentlyClosedTabsTreeStructure[index].parent == TreeBehavior.STRUCTURE_KEEP_PARENT || lastRecentlyClosedTabsTreeStructure[index].parent == TreeBehavior.STRUCTURE_NO_PARENT); + log(`tryRestoreClosedSetFor: rootTabs, restoredTabs = `, rootTabs, restoredTabs); + for (const rootTab of rootTabs) { + const referenceTabs = TreeBehavior.calculateReferenceItemsFromInsertionPosition(rootTab, { + context: Constants.kINSERTION_CONTEXT_MOVED, + insertAfter: rootTab.$TST.previousTab, + insertBefore: restoredTabs[restoredTabs.length - 1].$TST.nextTab + }); + log(`tryRestoreClosedSetFor: referenceTabs for ${rootTab.id} => `, referenceTabs); + if (referenceTabs.parent) + await Tree.attachTabTo(rootTab, referenceTabs.parent, { + dontExpand: true, + insertAfter: referenceTabs.insertAfter, + dontMove: true, + broadcast: true + }); + } + + await Tree.applyTreeStructureToTabs( + restoredTabs, + lastRecentlyClosedTabsTreeStructure + ); + + // Firefox itself activates the initially restored tab with delay, + // so we need to activate the first tab of the restored tabs again. + mToBeActivatedRestoredTabId = restoredTabs[0].id; + wait(100).then(() => onRestoredTabActivated({ id: -1 })); // failsafe +} diff --git a/waterfox/browser/components/sidebar/background/tree.js b/waterfox/browser/components/sidebar/background/tree.js new file mode 100644 index 000000000000..43c59af9d774 --- /dev/null +++ b/waterfox/browser/components/sidebar/background/tree.js @@ -0,0 +1,2251 @@ +/* ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is the Tree Style Tab. + * + * The Initial Developer of the Original Code is YUKI "Piro" Hiroshi. + * Portions created by the Initial Developer are Copyright (C) 2011-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): YUKI "Piro" Hiroshi + * wanabe + * Tetsuharu OHZEKI + * Xidorn Quan (Firefox 40+ support) + * lv7777 (https://github.com/lv7777) + * + * ***** END LICENSE BLOCK ******/ +'use strict'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; + +import { + log as internalLogger, + wait, + dumpTab, + mapAndFilter, + configs, + shouldApplyAnimation, + getWindowParamsFromSource, + isFirefoxViewTab, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from '/common/constants.js'; +import * as SidebarConnection from '/common/sidebar-connection.js'; +import * as TabsInternalOperation from '/common/tabs-internal-operation.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TreeBehavior from '/common/tree-behavior.js'; +import * as TSTAPI from '/common/tst-api.js'; +import * as UserOperationBlocker from '/common/user-operation-blocker.js'; + +import MetricsData from '/common/MetricsData.js'; +import { Tab, TreeItem } from '/common/TreeItem.js'; +import Window from '/common/Window.js'; + +import * as TabsMove from './tabs-move.js'; + +function log(...args) { + internalLogger('background/tree', ...args); +} +function logCollapseExpand(...args) { + internalLogger('sidebar/collapse-expand', ...args); +} + + +export const onAttached = new EventListenerManager(); +export const onDetached = new EventListenerManager(); +export const onSubtreeCollapsedStateChanging = new EventListenerManager(); +export const onSubtreeCollapsedStateChanged = new EventListenerManager(); + + +const mUnattachableTabIds = new Set(); + +export function markTabIdAsUnattachable(id) { + mUnattachableTabIds.add(id); +} + +export function clearUnattachableTabId(id) { + mUnattachableTabIds.delete(id); +} + +function isTabIdUnattachable(id) { + return mUnattachableTabIds.has(id); +} + + +// return moved (or not) +export async function attachTabTo(child, parent, options = {}) { + parent = TabsStore.ensureLivingItem(parent); + child = TabsStore.ensureLivingItem(child); + if (!parent || !child) { + log('missing information: ', { parent, child }); + return false; + } + + if (isFirefoxViewTab(parent)) { + log('Firefox View tab could not be a parent of other tabs'); + return false; + } + + log('attachTabTo: ', { + child: child.id, + parent: parent.id, + children: parent.$TST.getAttribute(Constants.kCHILDREN), + insertAt: options.insertAt, + insertBefore: options.insertBefore?.id, + insertAfter: options.insertAfter?.id, + lastRelatedTab: options.lastRelatedTab?.id, + dontMove: options.dontMove, + dontUpdateIndent: options.dontUpdateIndent, + forceExpand: options.forceExpand, + dontExpand: options.dontExpand, + delayedMove: options.delayedMove, + dontSyncParentToOpenerTab: options.dontSyncParentToOpenerTab, + broadcast: options.broadcast, + broadcasted: options.broadcasted, + stack: `${configs.debug && new Error().stack}\n${options.stack || ''}` + }); + + if (isTabIdUnattachable(child.id)) { + log('=> do not attach an unattachable tab to another (maybe already removed)'); + return false; + } + if (isTabIdUnattachable(parent.id)) { + log('=> do not attach to an unattachable tab (maybe already removed)'); + return false; + } + + if (parent.pinned || child.pinned) { + log('=> pinned tabs cannot be attached'); + return false; + } + if (parent.windowId != child.windowId) { + log('=> could not attach tab to a parent in different window'); + return false; + } + const ancestors = [parent].concat(parent.$TST.ancestors); + if (ancestors.includes(child)) { + log('=> canceled for recursive request'); + return false; + } + + if (options.dontMove) { + log('=> do not move'); + options.insertBefore = child.$TST.nextTab; + if (!options.insertBefore) + options.insertAfter = child.$TST.previousTab; + } + + if (!options.insertBefore && !options.insertAfter) { + const refTabs = getReferenceTabsForNewChild(child, parent, options); + options.insertBefore = refTabs.insertBefore; + options.insertAfter = refTabs.insertAfter; + log('=> calculate reference tabs ', refTabs); + } + options.insertAfter = options.insertAfter || parent; + log(`reference tabs for ${child.id}: `, { + insertBefore: options.insertBefore, + insertAfter: options.insertAfter + }); + + if (!options.synchronously) + await Tab.waitUntilTrackedAll(child.windowId); + + parent = TabsStore.ensureLivingItem(parent); + child = TabsStore.ensureLivingItem(child); + if (!parent || !child) { + log('attachTabTo: parent or child is closed before attaching.'); + return false; + } + if (isTabIdUnattachable(child.id) || isTabIdUnattachable(parent.id)) { + log('attachTabTo: parent or child is marked as unattachable (maybe already removed)'); + return false; + } + + parent.$TST.invalidateCache(); + child.$TST.invalidateCache(); + + const newIndex = Tab.calculateNewTabIndex({ + insertBefore: options.insertBefore, + insertAfter: options.insertAfter, + ignoreTabs: [child] + }); + const moved = newIndex != child.index; + log(`newIndex for ${child.id}: `, newIndex); + + const newlyAttached = ( + !parent.$TST.childIds.includes(child.id) || + child.$TST.parentId != parent.id + ); + if (!newlyAttached) + log('=> already attached'); + + if (newlyAttached) { + detachTab(child, { + ...options, + // Don't broadcast this detach operation, because this "attachTabTo" can be + // broadcasted. If we broadcast this detach operation, the tab is detached + // twice in the sidebar! + broadcast: false + }); + + log('attachTabTo: setting child information to ', parent.id); + // we need to set its children via the "children" setter, to invalidate cached information. + parent.$TST.children = parent.$TST.childIds.concat([child.id]); + + // We don't need to update its parent information, because the parent's + // "children" setter updates the child itself automatically. + + const parentLevel = parseInt(parent.$TST.getAttribute(Constants.kLEVEL) || 0); + if (!options.dontUpdateIndent) + updateTabsIndent(child, parentLevel + 1, { justNow: options.synchronously }); + + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_CHILDREN_CHANGED, + windowId: parent.windowId, + tabId: parent.id, + childIds: parent.$TST.childIds, + addedChildIds: [child.id], + removedChildIds: [], + newlyAttached + }); + if (TSTAPI.hasListenerForMessageType(TSTAPI.kNOTIFY_TREE_ATTACHED)) { + const cache = {}; + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_TREE_ATTACHED, + tab: child, + parent, + }, { tabProperties: ['tab', 'parent'], cache }).catch(_error => {}); + TSTAPI.clearCache(cache); + } + } + + if (child.openerTabId != parent.id && + !options.dontSyncParentToOpenerTab && + configs.syncParentTabAndOpenerTab) { + log(`openerTabId of ${child.id} is changed by TST!: ${child.openerTabId} (original) => ${parent.id} (changed by TST)`, new Error().stack); + child.openerTabId = parent.id; + child.$TST.updatingOpenerTabIds.push(parent.id); + browser.tabs.update(child.id, { openerTabId: parent.id }) + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + wait(200).then(() => { + const index = child.$TST.updatingOpenerTabIds.findIndex(id => id == parent.id); + child.$TST.updatingOpenerTabIds.splice(index, 1); + }); + } + + if (newlyAttached) + await collapseExpandForAttachedTab(child, parent, options); + + if (!options.dontMove) { + let nextTab = options.insertBefore; + let prevTab = options.insertAfter; + if (!nextTab && !prevTab) { + nextTab = Tab.getTabAt(child.windowId, newIndex); + if (!nextTab) + prevTab = Tab.getTabAt(child.windowId, newIndex - 1); + } + log('move newly attached child: ', dumpTab(child), { + next: dumpTab(nextTab), + prev: dumpTab(prevTab) + }); + if (!nextTab || + // We should not use a descendant of the "child" tab as the reference tab + // when we are going to attach the "child" and its descendants to the new + // parent. + // See also: https://github.com/piroor/treestyletab/issues/2892#issuecomment-862424942 + nextTab.$TST.parent == child) { + await moveTabSubtreeAfter(child, prevTab, { + ...options, + broadcast: true + }); + } + else { + await moveTabSubtreeBefore(child, nextTab, { + ...options, + broadcast: true + }); + } + } + + child.$TST.opened.then(() => { + if (!TabsStore.ensureLivingItem(child) || // not removed while waiting + child.$TST.parent != parent) // not detached while waiting + return; + + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_ATTACHED_COMPLETELY, + windowId: child.windowId, + childId: child.id, + parentId: parent.id, + newlyAttached + }); + }); + + onAttached.dispatch(child, { + ...options, + parent, + insertBefore: options.insertBefore, + insertAfter: options.insertAfter, + newIndex, newlyAttached + }); + + return !options.dontMove && moved; +} + +async function collapseExpandForAttachedTab(tab, parent, options = {}) { + // Because the tab is possibly closing for "reopen" operation, + // we need to apply "forceExpand" immediately. Otherwise, when + // the tab is closed with "subtree collapsed" state, descendant + // tabs are also closed even if "forceExpand" is "true". + log('collapseExpandForAttachedTab: newly attached tab ', { tab, parent, options }); + if (parent.$TST.subtreeCollapsed && + !options.forceExpand) { + log(' the tree is collapsed, but keep collapsed by forceExpand option'); + collapseExpandTabAndSubtree(tab, { + collapsed: true, + justNow: true, + broadcast: true + }); + } + + const isNewTreeCreatedManually = !options.justNow && parent.$TST.childIds.length == 1; + let parentTreeCollasped = parent.$TST.subtreeCollapsed; + let parentCollasped = parent.$TST.collapsed; + + const cache = {}; + const allowed = (options.forceExpand || !options.dontExpand) && await TSTAPI.tryOperationAllowed( + TSTAPI.kNOTIFY_TRY_EXPAND_TREE_FROM_ATTACHED_CHILD, + { tab: parent, + child: tab }, + { tabProperties: ['tab', 'child'], cache } + ); + TSTAPI.clearCache(cache); + if (!TabsStore.ensureLivingItem(tab)) { + log(' not living tab, do nothing'); + return; + } + + if (options.forceExpand && allowed) { + log(` expand tab ${tab.id} by forceExpand option`); + if (parentTreeCollasped) + collapseExpandSubtree(parent, { + ...options, + collapsed: false, + broadcast: true + }); + else + collapseExpandTabAndSubtree(tab, { + ...options, + collapsed: false, + broadcast: true + }); + parentTreeCollasped = false; + } + else { + log(' not forceExpanded'); + } + + if (!options.dontExpand) { + if (allowed) { + if (configs.autoCollapseExpandSubtreeOnAttach && + (isNewTreeCreatedManually || + parent.$TST.isAutoExpandable)) { + log(' collapse others by collapseExpandTreesIntelligentlyFor'); + await collapseExpandTreesIntelligentlyFor(parent, { + broadcast: true + }); + } + if (configs.autoCollapseExpandSubtreeOnSelect || + isNewTreeCreatedManually || + parent.$TST.isAutoExpandable || + options.forceExpand) { + log(' expand ancestor tabs'); + parentTreeCollasped = false; + parentCollasped = false; + await Promise.all([parent].concat(parent.$TST.ancestors).map(async ancestor => { + if (!ancestor.$TST.subtreeCollapsed) + return; + const allowed = await TSTAPI.tryOperationAllowed( + TSTAPI.kNOTIFY_TRY_EXPAND_TREE_FROM_ATTACHED_CHILD, + { tab: ancestor, + child: tab }, + { tabProperties: ['tab', 'child'], cache } + ); + TSTAPI.clearCache(cache); + if (!allowed) { + parentTreeCollasped = true; + parentCollasped = true; + return; + } + if (!TabsStore.ensureLivingItem(tab)) + return; + collapseExpandSubtree(ancestor, { + ...options, + collapsed: false, + broadcast: true + }); + parentTreeCollasped = false; + })); + if (!TabsStore.ensureLivingItem(tab)) + return; + } + if (!parent.$TST.subtreeCollapsed && + tab.$TST.collapsed) { + log(' moved from collapsed tree to expanded tree'); + collapseExpandTabAndSubtree(tab, { + ...options, + collapsed: false, + broadcast: true, + }); + } + } + else { + log(' not allowed to expand'); + } + } + else if (parent.$TST.isAutoExpandable || + parent.$TST.collapsed) { + log(' collapse auto expanded tree'); + collapseExpandTabAndSubtree(tab, { + ...options, + collapsed: true, + broadcast: true + }); + } + else { + log(' nothing to do'); + } + if (parentTreeCollasped || parentCollasped) { + log(' collapse tab because the parent is collapsed'); + collapseExpandTabAndSubtree(tab, { + ...options, + collapsed: true, + forceExpand: false, + broadcast: true + }); + } +} + +export function getReferenceTabsForNewChild(child, parent, { insertAt, ignoreTabs, lastRelatedTab, children, descendants } = {}) { + log('getReferenceTabsForNewChild ', { child, parent, insertAt, ignoreTabs, lastRelatedTab, children, descendants }); + if (typeof insertAt !== 'number') + insertAt = configs.insertNewChildAt; + log(' insertAt = ', insertAt); + if (parent && !descendants) + descendants = parent.$TST.descendants; + if (ignoreTabs) + descendants = descendants.filter(tab => !ignoreTabs.includes(tab)); + log(' descendants = ', descendants); + let insertBefore, insertAfter; + if (descendants.length > 0) { + const firstChild = descendants[0]; + const lastDescendant = descendants[descendants.length - 1]; + switch (insertAt) { + case Constants.kINSERT_END: + default: + insertAfter = lastDescendant; + log(` insert ${child?.id} after lastDescendant ${insertAfter?.id} (insertAt=kINSERT_END)`); + break; + case Constants.kINSERT_TOP: + insertBefore = firstChild; + log(` insert ${child?.id} before firstChild ${insertBefore?.id} (insertAt=kINSERT_TOP)`); + break; + case Constants.kINSERT_NEAREST: { + const allTabs = Tab.getOtherTabs((child || parent).windowId, ignoreTabs); + const index = child ? allTabs.indexOf(child) : -1; + log(' insertAt=kINSERT_NEAREST ', { allTabs, index }); + if (index < allTabs.indexOf(firstChild)) { + insertBefore = firstChild; + insertAfter = parent; + log(` insert ${child?.id} between parent ${insertAfter?.id} and firstChild ${insertBefore?.id} (insertAt=kINSERT_NEAREST)`); + } + else if (index > allTabs.indexOf(lastDescendant)) { + insertAfter = lastDescendant; + log(` insert ${child?.id} after lastDescendant ${insertAfter?.id} (insertAt=kINSERT_NEAREST)`); + } + else { // inside the tree + if (parent && !children) + children = parent.$TST.children; + if (ignoreTabs) + children = children.filter(tab => !ignoreTabs.includes(tab)); + for (const child of children) { + if (index > allTabs.indexOf(child)) + continue; + insertBefore = child; + log(` insert ${child?.id} before nearest following child ${insertBefore?.id} (insertAt=kINSERT_NEAREST)`); + break; + } + if (!insertBefore) { + insertAfter = lastDescendant; + log(` insert ${child?.id} after lastDescendant ${insertAfter?.id} (insertAt=kINSERT_NEAREST)`); + } + } + }; break; + case Constants.kINSERT_NEXT_TO_LAST_RELATED_TAB: { + // Simulates Firefox's default behavior with `browser.tabs.insertRelatedAfterCurrent`=`true`. + // The result will become same to kINSERT_NO_CONTROL case, + // but this is necessary for environments with disabled the preference. + if ((lastRelatedTab === undefined) && parent) + lastRelatedTab = child && parent.$TST.lastRelatedTabId == child.id ? parent.$TST.previousLastRelatedTab : parent.$TST.lastRelatedTab; // it could be updated already... + if (lastRelatedTab) { + insertAfter = lastRelatedTab.$TST.lastDescendant || lastRelatedTab; + log(` insert ${child?.id} after lastRelatedTab ${lastRelatedTab.id} (insertAt=kINSERT_NEXT_TO_LAST_RELATED_TAB)`); + } + else { + insertBefore = firstChild; + log(` insert ${child?.id} before firstChild (insertAt=kINSERT_NEXT_TO_LAST_RELATED_TAB)`); + } + }; break; + case Constants.kINSERT_NO_CONTROL: + break; + } + } + else { + insertAfter = parent; + log(` insert ${child?.id} after parent`); + } + if (insertBefore == child) { + // Return unsafe tab, to avoid placing the child after hidden tabs + // (too far from the place it should be.) + insertBefore = insertBefore?.$TST.unsafeNextTab; + log(` => insert ${child?.id} before next tab ${insertBefore?.id} of the child tab itelf`); + } + if (insertAfter == child) { + insertAfter = insertAfter?.$TST.previousTab; + log(` => insert ${child?.id} after previous tab ${insertAfter?.id} of the child tab itelf`); + } + // disallow to place tab in invalid position + if (insertBefore) { + if (parent && insertBefore.index <= parent.index) { + insertBefore = null; + log(` => do not put ${child?.id} before a tab preceding to the parent`); + } + //TODO: we need to reject more cases... + } + if (insertAfter) { + const allTabsInTree = [...descendants]; + if (parent) + allTabsInTree.unshift(parent); + const lastMember = allTabsInTree[allTabsInTree.length - 1]; + if (lastMember != insertAfter && + insertAfter.index >= lastMember.index) { + insertAfter = lastMember; + log(` => do not put ${child?.id} after the last tab ${insertAfter?.id} in the tree`); + } + //TODO: we need to reject more cases... + } + return { insertBefore, insertAfter }; +} + +export function getReferenceTabsForNewNextSibling(base, options = {}) { + log('getReferenceTabsForNewNextSibling ', base); + let insertBefore = base.$TST.nextSiblingTab; + if (insertBefore?.pinned && + !options.pinned) { + insertBefore = Tab.getFirstNormalTab(base.windowId); + } + let insertAfter = base.$TST.lastDescendant || base; + if (insertAfter && + !insertAfter.pinned && + options.pinned) { + insertAfter = Tab.getLastPinnedTab(base.windowId); + } + return { insertBefore, insertAfter }; +} + +export function detachTab(child, options = {}) { + log('detachTab: ', child.id, options, + { stack: `${configs.debug && new Error().stack}\n${options.stack || ''}` }); + // the "parent" option is used for removing child. + const parent = TabsStore.ensureLivingItem(options.parent) || child.$TST.parent; + + if (parent) { + // we need to set children and parent via setters, to invalidate cached information. + parent.$TST.children = parent.$TST.childIds.filter(id => id != child.id); + parent.$TST.invalidateCache(); + log('detachTab: children information is updated ', parent.id, parent.$TST.childIds); + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_CHILDREN_CHANGED, + windowId: parent.windowId, + tabId: parent.id, + childIds: parent.$TST.childIds, + addedChildIds: [], + removedChildIds: [child.id], + detached: true + }); + if (TSTAPI.hasListenerForMessageType(TSTAPI.kNOTIFY_TREE_DETACHED)) { + const cache = {}; + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_TREE_DETACHED, + tab: child, + oldParent: parent, + }, { tabProperties: ['tab', 'oldParent'], cache }).catch(_error => {}); + TSTAPI.clearCache(cache); + } + // We don't need to clear its parent information, because the old parent's + // "children" setter removes the parent ifself from the detached child + // automatically. + } + else { + log(` => parent(${child.$TST.parentId}) is already removed, or orphan tab`); + // This can happen when the parent tab was detached via the native tab bar + // or Firefox's built-in command to detach tab from window. + } + + if (!options.toBeRemoved && !options.toBeDetached) + updateTabsIndent(child); + + if (child.openerTabId && + !options.dontSyncParentToOpenerTab && + configs.syncParentTabAndOpenerTab) { + log(`openerTabId of ${child.id} is cleared by TST!: ${child.openerTabId} (original)`, configs.debug && new Error().stack); + child.openerTabId = child.id; + browser.tabs.update(child.id, { openerTabId: child.id }) // set self id instead of null, because it requires any valid tab id... + .catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + } + child.$TST.invalidateCache(); + + onDetached.dispatch(child, { + oldParentTab: parent, + toBeRemoved: !!options.toBeRemoved, + toBeDetached: !!options.toBeDetached + }); +} + +export function getWholeTree(rootTabs) { + if (!Array.isArray(rootTabs)) + rootTabs = [rootTabs]; + const wholeTree = [...rootTabs]; + for (const rootTab of rootTabs) { + wholeTree.push(...rootTab.$TST.descendants); + } + return TreeItem.sort([...new Set(wholeTree)]); +} + +export async function detachTabsFromTree(tabs, options = {}) { + if (!Array.isArray(tabs)) + tabs = [tabs]; + tabs = Array.from(tabs).reverse(); + // you should specify this option if you already call "Tree.getWholeTree()" for the tabs. + const partial = 'partial' in options ? + options.partial : + getWholeTree(tabs).length != tabs.length; + const promisedAttach = []; + const tabsSet = new Set(tabs); + for (const tab of tabs) { + let behavior = partial ? + TreeBehavior.getParentTabOperationBehavior(tab, { + context: Constants.kPARENT_TAB_OPERATION_CONTEXT_CLOSE, + }) : + Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD; + if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE) + behavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD; + promisedAttach.push(detachAllChildren(tab, { + ...options, + behavior, + ignoreTabs: tabs, + })); + if (options.fromParent && + !tabsSet.has(tab.$TST.parent)) { + promisedAttach.push(detachTab(tab, options)); + } + } + if (promisedAttach.length > 0) + await Promise.all(promisedAttach); +} + +export async function detachAllChildren( + tab = null, + { windowId, children, descendants, parent, nearestFollowingRootTab, newParent, ignoreTabs, behavior, dontExpand, dontSyncParentToOpenerTab, + ...options } = {} +) { + if (tab) { + windowId = tab.$TST.windowId; + parent = tab.$TST.parent; + children = tab.$TST.children; + descendants = tab.$TST.descendants; + } + log('detachAllChildren: ', + tab?.id, + { children, parent, nearestFollowingRootTab, newParent, behavior, dontExpand, dontSyncParentToOpenerTab }, + options); + // the "children" option is used for removing tab. + children = children ? children.map(TabsStore.ensureLivingItem) : tab.$TST.children; + + const ignoreTabsSet = new Set(ignoreTabs || []); + + if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD && + newParent && + !children.includes(newParent)) + children.unshift(newParent); + + if (!children.length) + return; + log(' => children to be detached: ', () => children.map(dumpTab)); + + if (behavior === undefined) + behavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_SIMPLY_DETACH_ALL_CHILDREN; + if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE) + behavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD; + + options.dontUpdateInsertionPositionInfo = true; + + // the "parent" option is used for removing tab. + parent = TabsStore.ensureLivingItem(parent) || tab?.$TST.parent; + while (ignoreTabsSet.has(parent)) { + parent = parent.$TST.parent; + } + if (tab?.$TST.isGroupTab && + Tab.getRemovingTabs(tab.windowId).length == children.length) { + behavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN; + options.dontUpdateIndent = false; + } + + let previousTab = null; + let nextTab = null; + if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_DETACH_ALL_CHILDREN && + !configs.moveTabsToBottomWhenDetachedFromClosedParent) { + nextTab = nearestFollowingRootTab !== undefined ? + nearestFollowingRootTab : + tab?.$TST.nearestFollowingRootTab; + previousTab = nextTab ? + nextTab.$TST.previousTab : + Tab.getLastTab(windowId || tab.windowId); + const descendantsSet = new Set(descendants || tab.$TST.descendants); + while (previousTab && (!tab || descendantsSet.has(previousTab))) { + previousTab = previousTab.$TST.previousTab; + } + } + + if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB) { + // open new group tab and replace the detaching tab with it. + behavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN; + } + + if (!dontExpand && + ((tab && !tab.$TST.collapsed) || + (behavior != Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE && + behavior != Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB))) { + if (tab) { + await collapseExpandSubtree(tab, { + ...options, + collapsed: false + }); + } + else { + for (const child of children) { + await collapseExpandTabAndSubtree(child, { + ...options, + collapsed: false, + forceExpand: behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_DETACH_ALL_CHILDREN, + }); + } + } + } + + let count = 0; + for (const child of children) { + if (!child) + continue; + const promises = []; + if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_DETACH_ALL_CHILDREN) { + promises.push(detachTab(child, { ...options, dontSyncParentToOpenerTab })); + + // reference tabs can be closed while waiting... + if (nextTab?.$TST.removing) + nextTab = null; + if (previousTab?.$TST.removing) + previousTab = null; + + if (nextTab) { + promises.push(moveTabSubtreeBefore(child, nextTab, options)); + } + else { + promises.push(moveTabSubtreeAfter(child, previousTab, options)); + previousTab = child.$TST.lastDescendant || child; + } + } + else if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD) { + promises.push(detachTab(child, { ...options, dontSyncParentToOpenerTab })); + if (count == 0) { + if (parent) { + promises.push(attachTabTo(child, parent, { + ...options, + dontSyncParentToOpenerTab, + dontExpand: true, + dontMove: true + })); + } + promises.push(collapseExpandSubtree(child, { + ...options, + collapsed: false + })); + //deleteTabValue(child, Constants.kTAB_STATE_SUBTREE_COLLAPSED); + } + else { + promises.push(attachTabTo(child, children[0], { + ...options, + dontSyncParentToOpenerTab, + dontExpand: true, + dontMove: true + })); + } + } + else if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN && + parent) { + promises.push(attachTabTo(child, parent, { + ...options, + dontSyncParentToOpenerTab, + dontExpand: true, + dontMove: true + })); + } + else { // behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_SIMPLY_DETACH_ALL_CHILDREN + promises.push(detachTab(child, { ...options, dontSyncParentToOpenerTab })); + } + count++; + await Promise.all(promises); + } +} + +// returns moved (or not) +export async function behaveAutoAttachedTab( + tab, + { baseTab, behavior, broadcast, dontMove } = {} +) { + if (!configs.autoAttach) + return false; + + baseTab = baseTab || Tab.getActiveTab(TabsStore.getCurrentWindowId() || tab.windowId); + log('behaveAutoAttachedTab ', tab.id, baseTab.id, { baseTab, behavior }); + + if (baseTab?.$TST.ancestors.includes(tab)) { + log(' => ignore possibly restored ancestor tab to avoid cyclic references'); + return false; + } + + if (baseTab.pinned) { + if (!tab.pinned) + return false; + behavior = Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING; + log(' => override behavior for pinned tabs'); + } + switch (behavior) { + default: + return false; + + case Constants.kNEWTAB_OPEN_AS_ORPHAN: + log(' => kNEWTAB_OPEN_AS_ORPHAN'); + detachTab(tab, { + broadcast + }); + if (tab.$TST.nextTab) + return TabsMove.moveTabAfter(tab, Tab.getLastTab(tab.windowId), { + delayedMove: true + }); + return false; + + case Constants.kNEWTAB_OPEN_AS_CHILD_NEXT_TO_LAST_RELATED_TAB: + log(' => kNEWTAB_OPEN_AS_CHILD_NEXT_TO_LAST_RELATED_TAB'); + const lastRelatedTab = baseTab.$TST.lastRelatedTab; + if (lastRelatedTab) { + log(` place after last related tab ${dumpTab(lastRelatedTab)}`); + await TabsMove.moveTabAfter(tab, lastRelatedTab.$TST.lastDescendant || lastRelatedTab, { + delayedMove: true, + broadcast: true + }); + return attachTabTo(tab, baseTab, { + insertAfter: lastRelatedTab, + lastRelatedTab, + forceExpand: true, + delayedMove: true, + broadcast + }); + } + log(` no lastRelatedTab: fallback to kNEWTAB_OPEN_AS_CHILD`); + case Constants.kNEWTAB_OPEN_AS_CHILD: + log(' => kNEWTAB_OPEN_AS_CHILD'); + return attachTabTo(tab, baseTab, { + dontMove: dontMove || configs.insertNewChildAt == Constants.kINSERT_NO_CONTROL, + forceExpand: true, + delayedMove: true, + broadcast + }); + + case Constants.kNEWTAB_OPEN_AS_CHILD_TOP: + log(' => kNEWTAB_OPEN_AS_CHILD_TOP'); + return attachTabTo(tab, baseTab, { + dontMove, + forceExpand: true, + delayedMove: true, + insertAt: Constants.kINSERT_TOP, + broadcast + }); + + case Constants.kNEWTAB_OPEN_AS_CHILD_END: + log(' => kNEWTAB_OPEN_AS_CHILD_END'); + return attachTabTo(tab, baseTab, { + dontMove, + forceExpand: true, + delayedMove: true, + insertAt: Constants.kINSERT_END, + broadcast + }); + + case Constants.kNEWTAB_OPEN_AS_SIBLING: { + log(' => kNEWTAB_OPEN_AS_SIBLING'); + const parent = baseTab.$TST.parent; + if (parent) { + await attachTabTo(tab, parent, { + delayedMove: true, + broadcast + }); + return true; + } + else { + detachTab(tab, { + broadcast + }); + return TabsMove.moveTabAfter(tab, Tab.getLastTab(tab.windowId), { + delayedMove: true + }); + } + }; + + case Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING: + case Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING_WITH_INHERITED_CONTAINER: { + log(' => kNEWTAB_OPEN_AS_NEXT_SIBLING(_WITH_INHERITED_CONTAINER)'); + let nextSibling = baseTab.$TST.nextSiblingTab; + if (nextSibling == tab) + nextSibling = null; + const parent = baseTab.$TST.parent; + if (parent) { + return attachTabTo(tab, parent, { + insertBefore: nextSibling, + insertAfter: baseTab.$TST.lastDescendant || baseTab, + delayedMove: true, + broadcast + }); + } + else { + detachTab(tab, { + broadcast + }); + if (nextSibling) + return TabsMove.moveTabBefore(tab, nextSibling, { + delayedMove: true, + broadcast + }); + else + return TabsMove.moveTabAfter(tab, baseTab.$TST.lastDescendant, { + delayedMove: true, + broadcast + }); + } + }; + } +} + +export async function behaveAutoAttachedTabs(tabs, options = {}) { + switch (options.behavior) { + default: + return false; + + case Constants.kNEWTAB_OPEN_AS_ORPHAN: + if (options.baseTabs && !options.baseTab) + options.baseTab = options.baseTabs[options.baseTabs.length - 1]; + for (const tab of tabs) { + await behaveAutoAttachedTab(tab, options); + } + return false; + + case Constants.kNEWTAB_OPEN_AS_CHILD: + case Constants.kNEWTAB_OPEN_AS_CHILD_TOP: + case Constants.kNEWTAB_OPEN_AS_CHILD_END: { + if (options.baseTabs && !options.baseTab) + options.baseTab = options.baseTabs[0]; + let moved = false; + for (const tab of tabs) { + moved = (await behaveAutoAttachedTab(tab, options)) || moved; + } + return moved; + }; + + case Constants.kNEWTAB_OPEN_AS_SIBLING: + case Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING: { + if (options.baseTabs && !options.baseTab) + options.baseTab = options.baseTabs[options.baseTabs.length - 1]; + let moved = false; + for (const tab of tabs.reverse()) { + moved = (await behaveAutoAttachedTab(tab, options)) || moved; + } + return moved; + }; + } +} + +function updateTabsIndent(tabs, level = undefined, options = {}) { + if (!tabs) + return; + + if (!Array.isArray(tabs)) + tabs = [tabs]; + + if (!tabs.length) + return; + + if (level === undefined) + level = tabs[0].$TST.ancestors.length; + + for (let i = 0, maxi = tabs.length; i < maxi; i++) { + const item = tabs[i]; + if (!item || item.pinned) + continue; + + updateTabIndent(item, level, options); + } +} + +// this is called multiple times on a session restoration, so this should be throttled for better performance +function updateTabIndent(tab, level = undefined, options = {}) { + let timer = updateTabIndent.delayed.get(tab.id); + if (timer) + clearTimeout(timer); + if (options.justNow || !shouldApplyAnimation()) { + return updateTabIndentNow(tab, level, options); + } + timer = setTimeout(() => { + updateTabIndent.delayed.delete(tab.id); + updateTabIndentNow(tab, level); + }, 100); + updateTabIndent.delayed.set(tab.id, timer); +} +updateTabIndent.delayed = new Map(); + +function updateTabIndentNow(tab, level = undefined, options = {}) { + if (!TabsStore.ensureLivingItem(tab)) + return; + tab.$TST.setAttribute(Constants.kLEVEL, level); + updateTabsIndent(tab.$TST.children, level + 1, options); + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_LEVEL_CHANGED, + windowId: tab.windowId, + tabId: tab.id, + level + }); +} + + +// collapse/expand tabs + +// returns an array of tab ids which are changed their visibility +export async function collapseExpandSubtree(tab, params = {}) { + params.collapsed = !!params.collapsed; + if (!tab || !TabsStore.ensureLivingItem(tab)) + return []; + if (!TabsStore.ensureLivingItem(tab)) // it was removed while waiting + return []; + params.stack = `${configs.debug && new Error().stack}\n${params.stack || ''}`; + logCollapseExpand('collapseExpandSubtree: ', dumpTab(tab), tab.$TST.subtreeCollapsed, params); + const visibilityChangedTabIds = await collapseExpandSubtreeInternal(tab, params); + onSubtreeCollapsedStateChanged.dispatch(tab, { collapsed: !!params.collapsed }); + if (TSTAPI.hasListenerForMessageType(TSTAPI.kNOTIFY_TREE_COLLAPSED_STATE_CHANGED)) { + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_TREE_COLLAPSED_STATE_CHANGED, + tab, + collapsed: !!params.collapsed + }, { tabProperties: ['tab'] }).catch(_error => {}); + } + return visibilityChangedTabIds; +} +async function collapseExpandSubtreeInternal(tab, params = {}) { + if (!params.force && + tab.$TST.subtreeCollapsed == params.collapsed) + return []; + + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_SUBTREE_COLLAPSED_STATE_CHANGING, + windowId: tab.windowId, + tabId: tab.id, + collapsed: !!params.collapsed, + }); + + if (params.collapsed) { + tab.$TST.addState(Constants.kTAB_STATE_SUBTREE_COLLAPSED); + tab.$TST.removeState(Constants.kTAB_STATE_SUBTREE_EXPANDED_MANUALLY); + } + else { + tab.$TST.removeState(Constants.kTAB_STATE_SUBTREE_COLLAPSED); + } + //setTabValue(tab, Constants.kTAB_STATE_SUBTREE_COLLAPSED, params.collapsed); + + const isInViewport = await browser.runtime.sendMessage({ + type: Constants.kCOMMAND_ASK_TAB_IS_IN_VIEWPORT, + windowId: tab.windowId, + tabId: tab.id, + allowPartial: true, + }).catch(_error => false); + const anchor = isInViewport ? tab : null; + + const childTabs = tab.$TST.children; + const lastExpandedTabIndex = childTabs.length - 1; + const allVisibilityChangedTabIds = []; + for (let i = 0, maxi = childTabs.length; i < maxi; i++) { + const childTab = childTabs[i]; + if (i == lastExpandedTabIndex && + !params.collapsed) { + allVisibilityChangedTabIds.push(...(await collapseExpandTabAndSubtree(childTab, { + collapsed: params.collapsed, + justNow: params.justNow, + anchor, + last: true, + broadcast: false + }))); + } + else { + allVisibilityChangedTabIds.push(...(await collapseExpandTabAndSubtree(childTab, { + collapsed: params.collapsed, + justNow: params.justNow, + broadcast: false + }))); + } + } + const visibilityChangedTabIds = [...new Set(allVisibilityChangedTabIds)]; + + onSubtreeCollapsedStateChanging.dispatch(tab, { collapsed: params.collapsed }); + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_SUBTREE_COLLAPSED_STATE_CHANGED, + windowId: tab.windowId, + tabId: tab.id, + collapsed: !!params.collapsed, + justNow: params.justNow, + anchorId: anchor?.id, + visibilityChangedTabIds, + last: true + }); + + return visibilityChangedTabIds; +} + +// returns an array of tab ids which are changed their visibility +export function manualCollapseExpandSubtree(tab, params = {}) { + params.manualOperation = true; + const visibilityChangedTabIds = collapseExpandSubtree(tab, params); + if (!params.collapsed) { + tab.$TST.addState(Constants.kTAB_STATE_SUBTREE_EXPANDED_MANUALLY); + //setTabValue(tab, Constants.kTAB_STATE_SUBTREE_EXPANDED_MANUALLY, true); + } + return visibilityChangedTabIds; +} + +// returns an array of tab ids which are changed their visibility +export async function collapseExpandTabAndSubtree(tab, params = {}) { + log('collapseExpandTabAndSubtree ', tab, params); + const visibilityChangedTabIds = []; + + if (!tab) { + log(' no target'); + return visibilityChangedTabIds; + } + + // allow to expand root collapsed tab + if (!tab.$TST.collapsed && + !tab.$TST.parent) { + log(' no parent'); + return visibilityChangedTabIds; + } + + if (collapseExpandTab(tab, params)) + visibilityChangedTabIds.push(tab.id); + + if (params.collapsed && + tab.active && + configs.unfocusableCollapsedTab) { + logCollapseExpand('current tree is going to be collapsed'); + const allowed = await TSTAPI.tryOperationAllowed( + TSTAPI.kNOTIFY_TRY_MOVE_FOCUS_FROM_COLLAPSING_TREE, + { tab }, + { tabProperties: ['tab'] } + ); + if (allowed) { + let newSelection = tab.$TST.nearestVisibleAncestorOrSelf; + if (configs.avoidDiscardedTabToBeActivatedIfPossible && newSelection.discarded) + newSelection = newSelection.$TST.nearestLoadedTabInTree || + newSelection.$TST.nearestLoadedTab || + newSelection; + logCollapseExpand('=> switch to ', newSelection.id); + TabsInternalOperation.activateTab(newSelection, { silently: true }); + } + } + + if (!tab.$TST.subtreeCollapsed) { + const children = tab.$TST.children; + const allVisibilityChangedTabs = await Promise.all(children.map((child, index) => { + const last = params.last && + (index == children.length - 1); + return collapseExpandTabAndSubtree(child, { + ...params, + collapsed: params.collapsed, + justNow: params.justNow, + anchor: last && params.anchor, + last: last, + broadcast: params.broadcast + }); + })); + visibilityChangedTabIds.push(...allVisibilityChangedTabs.flat()); + } + + return [...new Set(visibilityChangedTabIds)]; +} + +// returns true if the tab's visibility is changed +export async function collapseExpandTab(tab, params = {}) { + if (tab.pinned && params.collapsed) { + log('CAUTION: a pinned tab is going to be collapsed, but canceled.', + dumpTab(tab), { stack: configs.debug && new Error().stack }); + params.collapsed = false; + } + + // When an asynchronous "expand" operation is processed after a + // synchronous "collapse" operation, it can produce an expanded + // child tab under "subtree-collapsed" parent. So this is a failsafe. + if (!params.forceExpand && + !params.collapsed && + tab.$TST.ancestors.some(ancestor => ancestor.$TST.subtreeCollapsed)) { + log('collapseExpandTab: canceled to avoid expansion under collapsed tree ', + tab.$TST.ancestors.find(ancestor => ancestor.$TST.subtreeCollapsed)); + return false; + } + + const visibilityChanged = tab.$TST.collapsed != params.collapsed; + + const stack = `${configs.debug && new Error().stack}\n${params.stack || ''}`; + logCollapseExpand(`collapseExpandTab ${tab.id} `, params, { stack }) + const last = params.last && + (!tab.$TST.hasChild || tab.$TST.subtreeCollapsed); + const byAncestor = tab.$TST.ancestors.some(ancestor => ancestor.$TST.subtreeCollapsed) == params.collapsed; + const collapseExpandInfo = { + ...params, + anchor: last && params.anchor, + last + }; + + if (params.collapsed) { + tab.$TST.addState(Constants.kTAB_STATE_COLLAPSED); + TabsStore.removeVisibleTab(tab); + TabsStore.removeExpandedTab(tab); + } + else { + tab.$TST.removeState(Constants.kTAB_STATE_COLLAPSED); + TabsStore.addVisibleTab(tab); + TabsStore.addExpandedTab(tab); + } + + Tab.onCollapsedStateChanged.dispatch(tab, collapseExpandInfo); + + // the message is called multiple times on a session restoration, so it should be throttled for better performance + let timer = collapseExpandTab.delayedNotify.get(tab.id); + if (timer) + clearTimeout(timer); + timer = setTimeout(() => { + collapseExpandTab.delayedNotify.delete(tab.id); + if (!TabsStore.ensureLivingItem(tab)) + return; + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_COLLAPSED_STATE_CHANGED, + windowId: tab.windowId, + tabId: tab.id, + anchorId: collapseExpandInfo.anchor?.id, + justNow: params.justNow, + collapsed: params.collapsed, + last, + stack, + byAncestor + }); + }, shouldApplyAnimation() ? 100 : 0); + collapseExpandTab.delayedNotify.set(tab.id, timer); + + return visibilityChanged; +} +collapseExpandTab.delayedNotify = new Map(); + +export async function collapseExpandTreesIntelligentlyFor(tab, options = {}) { + if (!tab) + return; + + logCollapseExpand('collapseExpandTreesIntelligentlyFor ', tab); + const win = TabsStore.windows.get(tab.windowId); + if (win.doingIntelligentlyCollapseExpandCount > 0) { + logCollapseExpand('=> done by others'); + return; + } + win.doingIntelligentlyCollapseExpandCount++; + + try { + const expandedAncestors = [tab.id] + .concat(tab.$TST.ancestors.map(ancestor => ancestor.id)) + .concat(tab.$TST.descendants.map(descendant => descendant.id)); + const collapseTabs = Tab.getSubtreeCollapsedTabs(tab.windowId, { + '!id': expandedAncestors + }); + logCollapseExpand(`${collapseTabs.length} tabs can be collapsed, ancestors: `, expandedAncestors); + const allowedToCollapse = new Set(); + await Promise.all(collapseTabs.map(async tab => { + const allowed = await TSTAPI.tryOperationAllowed( + TSTAPI.kNOTIFY_TRY_COLLAPSE_TREE_FROM_OTHER_EXPANSION, + { tab }, + { tabProperties: ['tab'] } + ); + if (allowed) + allowedToCollapse.add(tab); + })); + for (const collapseTab of collapseTabs) { + if (!allowedToCollapse.has(collapseTab)) + continue; + let dontCollapse = false; + const parentTab = collapseTab.$TST.parent; + if (parentTab) { + dontCollapse = true; + if (!parentTab.$TST.subtreeCollapsed) { + for (const ancestor of collapseTab.$TST.ancestors) { + if (!expandedAncestors.includes(ancestor.id)) + continue; + dontCollapse = false; + break; + } + } + } + logCollapseExpand(`${collapseTab.id}: dontCollapse = ${dontCollapse}`); + + const manuallyExpanded = collapseTab.$TST.states.has(Constants.kTAB_STATE_SUBTREE_EXPANDED_MANUALLY); + if (!dontCollapse && + !manuallyExpanded && + collapseTab.$TST.descendants.every(tab => !tab.$TST.canBecomeSticky)) + collapseExpandSubtree(collapseTab, { + ...options, + collapsed: true + }); + } + + collapseExpandSubtree(tab, { + ...options, + collapsed: false + }); + } + catch(error) { + log(`failed to collapse/expand tree under ${tab.id}: ${String(error)}`, error); + } + win.doingIntelligentlyCollapseExpandCount--; +} + +export async function fixupSubtreeCollapsedState(tab, options = {}) { + let fixed = false; + if (!tab.$TST.hasChild) + return fixed; + const firstChild = tab.$TST.firstChild; + const childrenCollapsed = firstChild.$TST.collapsed; + const collapsedStateMismatched = tab.$TST.subtreeCollapsed != childrenCollapsed; + const nextIsFirstChild = tab.$TST.nextTab == firstChild; + log('fixupSubtreeCollapsedState ', { + tab: tab.id, + childrenCollapsed, + collapsedStateMismatched, + nextIsFirstChild + }); + if (collapsedStateMismatched) { + log(' => set collapsed state'); + await collapseExpandSubtree(tab, { + ...options, + collapsed: childrenCollapsed + }); + fixed = true; + } + if (!nextIsFirstChild) { + log(' => move child tabs'); + await followDescendantsToMovedRoot(tab, options); + fixed = true; + } + return fixed; +} + + +// operate tabs based on tree information + +export async function moveTabSubtreeBefore(tab, nextTab, options = {}) { + if (!tab) + return; + if (nextTab?.$TST.isAllPlacedBeforeSelf([tab].concat(tab.$TST.descendants))) { + log('moveTabSubtreeBefore:no need to move'); + return; + } + + log('moveTabSubtreeBefore: ', tab.id, nextTab?.id); + const win = TabsStore.windows.get(tab.windowId); + win.subTreeMovingCount++; + try { + await TabsMove.moveTabInternallyBefore(tab, nextTab, options); + if (!TabsStore.ensureLivingItem(tab)) // it is removed while waiting + throw new Error('the tab was removed before moving of descendants'); + await followDescendantsToMovedRoot(tab, options); + } + catch(error) { + log(`failed to move subtree: ${String(error)}`, error); + } + await wait(0); + win.subTreeMovingCount--; +} + +export async function moveTabSubtreeAfter(tab, previousTab, options = {}) { + if (!tab) + return; + + log('moveTabSubtreeAfter: ', tab.id, previousTab?.id); + if (previousTab?.$TST.isAllPlacedAfterSelf([tab].concat(tab.$TST.descendants))) { + log(' => no need to move'); + return; + } + + const win = TabsStore.windows.get(tab.windowId); + win.subTreeMovingCount++; + try { + await TabsMove.moveTabInternallyAfter(tab, previousTab, options); + if (!TabsStore.ensureLivingItem(tab)) // it is removed while waiting + throw new Error('the tab was removed before moving of descendants'); + await followDescendantsToMovedRoot(tab, options); + } + catch(error) { + log(`failed to move subtree: ${String(error)}`, error); + } + await wait(0); + win.subTreeMovingCount--; +} + +async function followDescendantsToMovedRoot(tab, options = {}) { + if (!tab.$TST.hasChild) + return; + + log('followDescendantsToMovedRoot: ', tab); + const win = TabsStore.windows.get(tab.windowId); + win.subTreeChildrenMovingCount++; + win.subTreeMovingCount++; + try { + await TabsMove.moveTabsAfter(tab.$TST.descendants, tab, options); + } + catch(error) { + log(`failed to move descendants of ${tab.id}: ${String(error)}`, error); + } + win.subTreeChildrenMovingCount--; + win.subTreeMovingCount--; +} + +// before https://bugzilla.mozilla.org/show_bug.cgi?id=1394376 is fixed (Firefox 67 or older) +let mSlowDuplication = false; +browser.runtime.getBrowserInfo().then(browserInfo => { + if (parseInt(browserInfo.version.split('.')[0]) < 68) + mSlowDuplication = true; +}); + +export async function moveTabs(tabs, { duplicate, ...options } = {}) { + tabs = tabs.filter(TabsStore.ensureLivingItem); + if (tabs.length == 0) + return []; + + log('moveTabs: ', () => ({ tabs: tabs.map(dumpTab), duplicate, options })); + + const windowId = parseInt(tabs[0].windowId || TabsStore.getCurrentWindowId()); + + let newWindow = options.destinationPromisedNewWindow; + + let destinationWindowId = options.destinationWindowId; + if (!destinationWindowId && !newWindow) { + destinationWindowId = TabsStore.getCurrentWindowId() || windowId; + } + + const isAcrossWindows = windowId != destinationWindowId || !!newWindow; + log('moveTabs: isAcrossWindows = ', isAcrossWindows, `${windowId} => ${destinationWindowId}`); + + options.insertAfter = options.insertAfter || Tab.getLastTab(destinationWindowId); + + let movedTabs = tabs; + const structure = TreeBehavior.getTreeStructureFromTabs(tabs); + log('original tree structure: ', structure); + + let hasActive = false; + for (const tab of movedTabs) { + if (tab.active) + hasActive = true; + if (isAcrossWindows && + !duplicate) + tab.$TST.temporaryMetadata.set('movingAcrossWindows', true); + } + + if (!duplicate) + await detachTabsFromTree(tabs, options); + + if (isAcrossWindows || duplicate) { + if (mSlowDuplication) + UserOperationBlocker.blockIn(windowId, { throbber: true }); + try { + let win; + const prepareWindow = () => { + win = Window.init(destinationWindowId); + if (isAcrossWindows) { + win.toBeOpenedTabsWithPositions += tabs.length; + win.toBeOpenedOrphanTabs += tabs.length; + for (const tab of tabs) { + win.toBeAttachedTabs.add(tab.id); + } + } + }; + if (newWindow) { + newWindow = newWindow.then(win => { + log('moveTabs: destination window is ready, ', win); + destinationWindowId = win.id; + prepareWindow(); + return win; + }); + } + else { + prepareWindow(); + } + + let movedTabIds = tabs.map(tab => tab.id); + await Promise.all([ + newWindow, + (async () => { + const sourceWindow = TabsStore.windows.get(tabs[0].windowId); + if (duplicate) { + sourceWindow.toBeOpenedTabsWithPositions += tabs.length; + sourceWindow.toBeOpenedOrphanTabs += tabs.length; + sourceWindow.duplicatingTabsCount += tabs.length; + } + if (isAcrossWindows) { + for (const tab of tabs) { + sourceWindow.toBeDetachedTabs.add(tab.id); + } + } + + log('preparing tabs'); + if (duplicate) { + const startTime = Date.now(); + // This promise will be resolved with very large delay. + // (See also https://bugzilla.mozilla.org/show_bug.cgi?id=1394376 ) + const promisedDuplicatedTabs = Promise.all(movedTabIds.map(async (id, _index) => { + try { + return await browser.tabs.duplicate(id).catch(ApiTabs.createErrorHandler()); + } + catch(e) { + ApiTabs.handleMissingTabError(e); + return null; + } + })).then(tabs => { + log(`ids from API responses are resolved in ${Date.now() - startTime}msec: `, () => tabs.map(dumpTab)); + return tabs; + }); + movedTabs = await promisedDuplicatedTabs; + if (mSlowDuplication) + UserOperationBlocker.setProgress(50, windowId); + movedTabs = movedTabs.map(tab => Tab.get(tab.id)); + movedTabIds = movedTabs.map(tab => tab.id); + } + else { + const movedTabIdsSet = new Set(movedTabIds); + for (const tab of movedTabs) { + tab.$TST.temporaryMetadata.set('movingAcrossWindows', true); + if (tab.$TST.parentId && + !movedTabIdsSet.has(tab.$TST.parentId)) + detachTab(tab, { + broadcast: true, + toBeDetached: true + }); + } + } + })() + ]); + log('moveTabs: all windows and tabs are ready, ', movedTabIds, destinationWindowId); + let toIndex = (tabs.some(tab => tab.pinned) ? Tab.getPinnedTabs(destinationWindowId) : Tab.getAllTabs(destinationWindowId)).length; + log('toIndex = ', toIndex); + if (options.insertBefore?.windowId == destinationWindowId) { + try { + toIndex = Tab.get(options.insertBefore.id).index; + } + catch(e) { + ApiTabs.handleMissingTabError(e); + log('options.insertBefore is unavailable'); + } + } + else if (options.insertAfter?.windowId == destinationWindowId) { + try { + toIndex = Tab.get(options.insertAfter.id).index + 1; + } + catch(e) { + ApiTabs.handleMissingTabError(e); + log('options.insertAfter is unavailable'); + } + } + if (!isAcrossWindows && + movedTabs[0].index < toIndex) + toIndex--; + log(' => ', toIndex); + if (isAcrossWindows) { + let temporaryFocusHolderTab = null; + if (hasActive) { + // Blur to-be-moved tab, otherwise tabs.move() will activate them for each + // while the moving process and all dicarded tabs are unexpectedly restored. + const nextActiveTab = await TabsInternalOperation.blurTab(movedTabs, { + silently: true, + }); + if (!nextActiveTab) { + // There is no focusible left tab, so we move focus to a tmeporary tab. + // It will be removed automatically after tabs are moved. + temporaryFocusHolderTab = await browser.tabs.create({ + url: 'about:blank', + active: true, + windowId + }); + } + } + movedTabs = await browser.tabs.move(movedTabIds, { + windowId: destinationWindowId, + index: toIndex + }); + if (temporaryFocusHolderTab) { + const leftTabsInSourceWindow = await browser.tabs.query({ windowId }); + if (leftTabsInSourceWindow.length == 1) + browser.windows.remove(windowId); + else + browser.tabs.remove(temporaryFocusHolderTab.id); + } + movedTabs = movedTabs.map(tab => Tab.get(tab.id)); + movedTabIds = movedTabs.map(tab => tab.id); + for (const tab of movedTabs) { + tab.$TST.temporaryMetadata.delete('movingAcrossWindows'); + tab.windowId = destinationWindowId; + } + log('moved across windows: ', movedTabIds); + } + + log('applying tree structure', structure); + // wait until tabs.onCreated are processed (for safety) + let newTabs; + const startTime = Date.now(); + const maxDelay = configs.maximumAcceptableDelayForTabDuplication; + while (Date.now() - startTime < maxDelay) { + newTabs = mapAndFilter(movedTabs, + tab => Tab.get(tab.id) || undefined); + if (mSlowDuplication) + UserOperationBlocker.setProgress(Math.round(newTabs.length / tabs.length * 50) + 50, windowId); + if (newTabs.length < tabs.length) { + log('retrying: ', movedTabIds, newTabs.length, tabs.length); + await wait(100); + continue; + } + await Promise.all(newTabs.map(tab => tab.$TST.opened)); + await applyTreeStructureToTabs(newTabs, structure, { + broadcast: true + }); + if (duplicate) { + for (const tab of newTabs) { + tab.$TST.removeState(Constants.kTAB_STATE_DUPLICATING, { broadcast: true }); + TabsStore.removeDuplicatingTab(tab); + } + } + break; + } + + if (!newTabs) { + log('failed to move tabs (timeout)'); + newTabs = []; + } + movedTabs = newTabs; + } + catch(e) { + if (configs.debug) + console.log('failed to move/duplicate tabs ', e, new Error().stack); + throw e; + } + finally { + if (mSlowDuplication) + UserOperationBlocker.unblockIn(windowId, { throbber: true }); + } + } + + + movedTabs = mapAndFilter(movedTabs, tab => Tab.get(tab.id) || undefined); + if (options.insertBefore) { + await TabsMove.moveTabsBefore( + movedTabs, + options.insertBefore, + options + ); + } + else if (options.insertAfter) { + await TabsMove.moveTabsAfter( + movedTabs, + options.insertAfter, + options + ); + } + else { + log('no move: just duplicate or import'); + } + // Tabs can be removed while waiting, so we need to + // refresh the array of tabs. + movedTabs = mapAndFilter(movedTabs, tab => Tab.get(tab.id) || undefined); + + if (isAcrossWindows) { + for (const tab of movedTabs) { + if (tab.$TST.parent || + parseInt(tab.$TST.getAttribute(Constants.kLEVEL) || 0) == 0) + continue; + updateTabIndent(tab, 0); + } + } + + return movedTabs; +} + +export async function openNewWindowFromTabs(tabs, options = {}) { + if (tabs.length == 0) + return []; + + log('openNewWindowFromTabs: ', tabs, options); + const sourceWindow = await browser.windows.get(tabs[0].windowId); + const sourceParams = getWindowParamsFromSource(sourceWindow, options); + const windowParams = { + //active: true, // not supported in Firefox... + url: 'about:blank', + ...sourceParams, + }; + // positions are not provided for a maximized or fullscren window! + if (typeof sourceParams.left == 'number') + sourceParams.left += 20; + if (typeof sourceParams.top == 'number') + sourceParams.top += 20; + let newWindow; + const promsiedNewWindow = browser.windows.create(windowParams) + .then(createdWindow => { + newWindow = createdWindow; + log('openNewWindowFromTabs: new window is ready, ', newWindow, windowParams); + UserOperationBlocker.blockIn(newWindow.id); + return newWindow; + }) + .catch(ApiTabs.createErrorHandler()); + tabs = tabs.filter(TabsStore.ensureLivingItem); + const movedTabs = await moveTabs(tabs, { + ...options, + destinationPromisedNewWindow: promsiedNewWindow + }); + + log('closing needless tabs'); + browser.windows.get(newWindow.id, { populate: true }) + .then(win => { + const movedTabIds = new Set(movedTabs.map(tab => tab.id)); + log('moved tabs: ', movedTabIds); + const removeTabs = mapAndFilter(win.tabs, tab => + !movedTabIds.has(tab.id) && Tab.get(tab.id) || undefined + ); + log('removing tabs: ', removeTabs); + TabsInternalOperation.removeTabs(removeTabs); + UserOperationBlocker.unblockIn(newWindow.id); + }) + .catch(ApiTabs.createErrorSuppressor()); + + return movedTabs; +} + + +/* "treeStructure" is an array of integers, meaning: + [A] => TreeBehavior.STRUCTURE_NO_PARENT (parent is not in this tree) + [B] => 0 (parent is 1st item in this tree) + [C] => 0 (parent is 1st item in this tree) + [D] => 2 (parent is 2nd in this tree) + [E] => TreeBehavior.STRUCTURE_NO_PARENT (parent is not in this tree, and this creates another tree) + [F] => 0 (parent is 1st item in this another tree) + See also getTreeStructureFromTabs() in tree-behavior.js +*/ +export async function applyTreeStructureToTabs(tabs, treeStructure, options = {}) { + if (!tabs || !treeStructure) + return; + + MetricsData.add('applyTreeStructureToTabs: start'); + + log('applyTreeStructureToTabs: ', () => ({ tabs: tabs.map(dumpTab), treeStructure, options })); + tabs = tabs.slice(0, treeStructure.length); + treeStructure = treeStructure.slice(0, tabs.length); + + let expandStates = tabs.map(tab => !!tab); + expandStates = expandStates.slice(0, tabs.length); + while (expandStates.length < tabs.length) + expandStates.push(TreeBehavior.STRUCTURE_NO_PARENT); + + MetricsData.add('applyTreeStructureToTabs: preparation'); + + let parent = null; + let tabsInTree = []; + const promises = []; + for (let i = 0, maxi = tabs.length; i < maxi; i++) { + const tab = tabs[i]; + /* + if (tab.$TST.collapsed) + collapseExpandTabAndSubtree(tab, { + ...options, + collapsed: false, + justNow: true + }); + */ + const structureInfo = treeStructure[i]; + let parentIndexInTree = TreeBehavior.STRUCTURE_NO_PARENT; + if (typeof structureInfo == 'number') { // legacy format + parentIndexInTree = structureInfo; + } + else { + parentIndexInTree = structureInfo.parent; + expandStates[i] = !structureInfo.collapsed; + } + log(` applyTreeStructureToTabs: parent for ${tab.id} => ${parentIndexInTree}`); + if (parentIndexInTree == TreeBehavior.STRUCTURE_NO_PARENT || + parentIndexInTree == TreeBehavior.STRUCTURE_KEEP_PARENT) { + // there is no parent, so this is a new parent! + parent = null; + tabsInTree = [tab]; + } + else { + tabsInTree.push(tab); + parent = parentIndexInTree < tabsInTree.length ? tabsInTree[parentIndexInTree] : null; + } + log(' => parent = ', parent); + if (parentIndexInTree != TreeBehavior.STRUCTURE_KEEP_PARENT) + detachTab(tab, { justNow: true }); + if (parent && tab != parent) { + parent.$TST.removeState(Constants.kTAB_STATE_SUBTREE_COLLAPSED); // prevent focus changing by "current tab attached to collapsed tree" + promises.push(attachTabTo(tab, parent, { + ...options, + dontExpand: true, + dontMove: true, + justNow: true + })); + } + } + if (promises.length > 0) + await Promise.all(promises); + MetricsData.add('applyTreeStructureToTabs: attach/detach'); + + log('expandStates: ', expandStates); + for (let i = tabs.length - 1; i > -1; i--) { + const tab = tabs[i]; + const expanded = expandStates[i]; + collapseExpandSubtree(tab, { + ...options, + collapsed: expanded === undefined ? !tab.$TST.hasChild : !expanded , + justNow: true, + force: true + }); + } + MetricsData.add('applyTreeStructureToTabs: collapse/expand'); +} + + + +//=================================================================== +// Fixup tree structure for unexpectedly inserted tabs +//=================================================================== + +class TabActionForNewPosition { + constructor(action, { tab, parent, insertBefore, insertAfter, isTabCreating, isMovingByShortcut, mustToApply } = {}) { + this.action = action || null; + this.tab = tab; + this.parent = parent; + this.insertBefore = insertBefore; + this.insertAfter = insertAfter; + this.isTabCreating = isTabCreating; + this.isMovingByShortcut = isMovingByShortcut; + this.mustToApply = mustToApply; + } + + async applyIfNeeded() { + if (!this.mustToApply) + return; + return this.apply(); + } + + async apply() { + log('TabActionForNewPosition: applying ', this); + switch (this.action) { + case 'invalid': + throw new Error('invalid action: this must not happen!'); + + case 'attach': { + const attached = attachTabTo(this.tab, Tab.get(this.parent), { + insertBefore: Tab.get(this.insertBefore), + insertAfter: Tab.get(this.insertAfter), + forceExpand: this.isTabCreating || this.isMovingByShortcut, + broadcast: true, + synchronously: this.isTabCreating, + }); + if (!this.isTabCreating) + await attached; + followDescendantsToMovedRoot(this.tab); + }; break; + + case 'detach': + detachTab(this.tab, { broadcast: true }); + followDescendantsToMovedRoot(this.tab); + if (!this.insertBefore && !this.insertAfter) + break; + + case 'move': + if (this.insertBefore) { + moveTabSubtreeBefore( + this.tab, + Tab.get(this.insertBefore), + { broadcast: true } + ); + return; + } + else if (this.insertAfter) { + moveTabSubtreeAfter( + this.tab, + Tab.get(this.insertAfter), + { broadcast: true } + ); + return; + } + + default: + followDescendantsToMovedRoot(this.tab); + break; + } + } +} + +export function detectTabActionFromNewPosition(tab, moveInfo = {}) { + const isTabCreating = !!moveInfo?.isTabCreating; + const isMovingByShortcut = !!moveInfo?.isMovingByShortcut; + + if (tab.pinned) + return new TabActionForNewPosition(tab.$TST.parentId ? 'detach' : 'move', { + tab, + isTabCreating, + isMovingByShortcut, + }); + + log('detectTabActionFromNewPosition: ', dumpTab(tab), moveInfo); + const tree = moveInfo.treeForActionDetection || snapshotForActionDetection(tab); + const target = tree.target; + log(' calculate new position: ', tab, tree); + + const toIndex = moveInfo.toIndex; + const fromIndex = moveInfo.fromIndex; + if (toIndex == fromIndex) { // no move? + log('=> no move'); + return new TabActionForNewPosition(); + } + + const prevTab = tree.tabsById[target.previous]; + const nextTab = tree.tabsById[target.next]; + + // When multiple tabs are moved at once by outside of TST (e.g. moving of multiselected tabs) + // this method may be called multiple times asynchronously before previous operation finishes. + // Thus we need to refer the calculated "parent" if it is given. + const futurePrevParent = Tab.get(Tab.get(prevTab?.id)?.$TST?.temporaryMetadata.get('goingToBeAttachedTo')); + const futureNextParent = Tab.get(Tab.get(nextTab?.id)?.$TST?.temporaryMetadata.get('goingToBeAttachedTo')); + + const prevParent = prevTab && tree.tabsById[prevTab.parent] || + snapshotTab(Tab.get(prevTab?.parent)) || // Given treeForActionDetection may not contain the parent tab, so failsafe + snapshotTab(futurePrevParent); + const nextParent = nextTab && tree.tabsById[nextTab.parent] || + snapshotTab(Tab.get(nextTab?.parent)) || // Given treeForActionDetection may not contain the parent tab, so failsafe + snapshotTab(futureNextParent); + if (prevParent) + tree.tabsById[prevParent.id] = prevParent; + if (nextParent) + tree.tabsById[nextParent.id] = nextParent; + + // Given treeForActionDetection may not contain the parent tab, so we fixup the information. + if (prevTab && + !prevTab.parent && + prevParent) { + prevTab.parent = prevParent.id; + prevTab.level = prevParent.level + 1; + } + if (nextTab && + !nextTab.parent && + nextParent) { + nextTab.parent = nextParent.id; + nextTab.level = nextParent.level + 1; + } + log('prevTab: ', dumpTab(prevTab), `parent: ${prevTab?.parent}`); + log('nextTab: ', dumpTab(nextTab), `parent: ${nextTab?.parent}`); + + const prevLevel = prevTab ? prevTab.level : -1 ; + const nextLevel = nextTab ? nextTab.level : -1 ; + log('prevLevel: '+prevLevel); + log('nextLevel: '+nextLevel); + + const oldParent = tree.tabsById[target.parent] || snapshotTab(Tab.get(target.parent)); + if (oldParent) + tree.tabsById[oldParent.id] = oldParent; + let newParent = null; + let mustToApply = false; + + if (!oldParent && + (!nextTab || + !nextParent)) { + if (!nextTab) + log('=> A root level tab, placed at the end of tabs. We should keep it in the root level.'); + else + log(' => A root level tab, placed before another root level tab. We should keep it in the root level.'); + return new TabActionForNewPosition('move', { + tab, + isTabCreating, + isMovingByShortcut, + insertAfter: prevTab?.id, + mustToApply, + }); + } + + if (target.mayBeReplacedWithContainer) { + log('=> replaced by Firefox Multi-Acount Containers or Temporary Containers'); + newParent = prevLevel < nextLevel ? prevTab : prevParent; + mustToApply = true; + } + else if (oldParent && + prevTab && + oldParent?.id == prevTab?.id) { + log('=> no need to fix case'); + newParent = oldParent; + } + else if (!prevTab) { + log('=> moved to topmost position'); + newParent = null; + mustToApply = !!oldParent; + } + else if (!nextTab) { + log('=> moved to last position'); + let ancestor = oldParent; + while (ancestor) { + if (ancestor.id == prevParent?.id) { + log(' => moving in related tree: keep it attached in existing tree'); + newParent = prevParent; + break; + } + ancestor = tree.tabsById[ancestor.parent]; + } + if (!newParent) { + log(' => moving from other tree: keep it orphaned'); + } + mustToApply = !!oldParent && newParent?.id != oldParent.id; + } + else if (prevParent?.id == nextParent?.id) { + log('=> moved into existing tree'); + newParent = prevParent; + mustToApply = !oldParent || newParent?.id != oldParent.id; + } + else if (prevLevel > nextLevel && + nextTab?.parent != tab.id) { + log('=> moved to end of existing tree'); + if (!target.active && + target.children.length == 0 && + (Date.now() - target.trackedAt) < 500) { + log('=> maybe newly opened tab'); + newParent = prevParent; + } + else { + log('=> maybe drag and drop (or opened with active state and position)'); + const realDelta = Math.abs(toIndex - fromIndex); + newParent = realDelta < 2 ? prevParent : (oldParent || nextParent) ; + } + while (newParent?.collapsed) { + log('=> the tree is collapsed, up to parent tree') + newParent = tree.tabsById[newParent.parent]; + } + mustToApply = !!oldParent && newParent?.id != oldParent.id; + } + else if (prevLevel < nextLevel && + nextTab?.parent == prevTab?.id) { + log('=> moved to first child position of existing tree'); + newParent = prevTab || oldParent || nextParent; + mustToApply = !!oldParent && newParent?.id != oldParent.id; + } + + log('calculated parent: ', { + old: oldParent?.id, + new: newParent?.id + }); + + if (newParent) { + let ancestor = newParent; + while (ancestor) { + if (ancestor.id == target.id) { + if (moveInfo.toIndex - moveInfo.fromIndex == 1) { + log('=> maybe move-down by keyboard shortcut or something.'); + let nearestForeigner = tab.$TST.nearestFollowingForeignerTab; + if (nearestForeigner && + nearestForeigner == tab) + nearestForeigner = nearestForeigner.$TST.nextTab; + log('nearest foreigner tab: ', nearestForeigner?.id); + if (nearestForeigner) { + if (nearestForeigner.$TST.hasChild) + return new TabActionForNewPosition('attach', { + tab, + isTabCreating, + isMovingByShortcut, + parent: nearestForeigner.id, + insertAfter: nearestForeigner.id, + mustToApply, + }); + return new TabActionForNewPosition(tab.$TST.parent ? 'detach' : 'move', { + tab, + isTabCreating, + isMovingByShortcut, + insertAfter: nearestForeigner.id, + mustToApply, + }); + } + } + log('=> invalid move: a parent is moved inside its own tree!'); + return new TabActionForNewPosition('invalid'); + } + ancestor = tree.tabsById[ancestor.parent]; + } + } + + if (newParent != oldParent) { + if (newParent) { + return new TabActionForNewPosition('attach', { + tab, + isTabCreating, + isMovingByShortcut, + parent: newParent.id, + insertBefore: nextTab?.id, + insertAfter: prevTab?.id, + mustToApply, + }); + } + else { + return new TabActionForNewPosition('detach', { + tab, + isTabCreating, + isMovingByShortcut, + mustToApply, + }); + } + } + return new TabActionForNewPosition('move', { + tab, + isTabCreating, + isMovingByShortcut, + mustToApply, + }); +} + + +//=================================================================== +// Take snapshot +//=================================================================== + +export function snapshotForActionDetection(targetTab) { + const prevTab = targetTab.$TST.nearestCompletelyOpenedNormalPrecedingTab; + const nextTab = targetTab.$TST.nearestCompletelyOpenedNormalFollowingTab; + const tabs = Array.from(new Set([ + ...(prevTab?.$TST?.ancestors || []), + prevTab, + targetTab, + nextTab, + targetTab.$TST.parent, + ])) + .filter(TabsStore.ensureLivingItem) + .sort((a, b) => a.index - b.index); + return snapshotTree(targetTab, tabs); +} + +function snapshotTree(targetTab, tabs) { + const allTabs = tabs || Tab.getTabs(targetTab.windowId); + + const snapshotById = {}; + function snapshotChild(tab) { + if (!TabsStore.ensureLivingItem(tab) || tab.pinned) + return null; + return snapshotById[tab.id] = snapshotTab(tab); + } + const snapshotArray = allTabs.map(tab => snapshotChild(tab)); + for (const tab of allTabs) { + const item = snapshotById[tab.id]; + if (!item) + continue; + item.parent = tab.$TST.parent?.id; + item.next = tab.$TST.nearestCompletelyOpenedNormalFollowingTab?.id; + item.previous = tab.$TST.nearestCompletelyOpenedNormalPrecedingTab?.id; + } + const activeTab = Tab.getActiveTab(targetTab.windowId); + return { + target: snapshotById[targetTab.id], + active: activeTab && snapshotById[activeTab.id], + tabs: snapshotArray, + tabsById: snapshotById, + }; +} + +function snapshotTab(tab) { + if (!tab) + return null; + return { + id: tab.id, + url: tab.url, + cookieStoreId: tab.cookieStoreId, + active: tab.active, + children: tab.$TST.children.map(child => child.id), + collapsed: tab.$TST.subtreeCollapsed, + pinned: tab.pinned, + level: tab.$TST.level, // parseInt(tab.$TST.getAttribute(Constants.kLEVEL) || 0), // we need to use the number of real ancestors instead of a cached "level", because it will be updated with delay + trackedAt: tab.$TST.trackedAt, + mayBeReplacedWithContainer: tab.$TST.mayBeReplacedWithContainer, + }; +} + + +SidebarConnection.onMessage.addListener(async (windowId, message) => { + switch (message.type) { + case Constants.kCOMMAND_SET_SUBTREE_COLLAPSED_STATE: { + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + if (!tab) + return; + const params = { + collapsed: message.collapsed, + justNow: message.justNow, + broadcast: true, + stack: message.stack + }; + if (message.manualOperation) + manualCollapseExpandSubtree(tab, params); + else + collapseExpandSubtree(tab, params); + }; break; + + case Constants.kCOMMAND_SET_SUBTREE_COLLAPSED_STATE_INTELLIGENTLY_FOR: { + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + if (tab) + await collapseExpandTreesIntelligentlyFor(tab); + }; break; + + case Constants.kCOMMAND_NEW_WINDOW_FROM_TABS: { + log('new window requested: ', message); + await Tab.waitUntilTracked(message.tabIds); + const tabs = message.tabIds.map(id => TabsStore.tabs.get(id)); + openNewWindowFromTabs(tabs, message); + }; break; + } +}); diff --git a/waterfox/browser/components/sidebar/common/MetricsData.js b/waterfox/browser/components/sidebar/common/MetricsData.js new file mode 100644 index 000000000000..8dc0288e399f --- /dev/null +++ b/waterfox/browser/components/sidebar/common/MetricsData.js @@ -0,0 +1,60 @@ +/* +# 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/. +*/ + +export default class MetricsData { + constructor(name) { + this.mItems = []; + this.mName = name || ''; + + const now = Date.now(); + this.mInitialTime = now; + this.mLastTime = now; + this.mDeltaBetweenLastItem = 0; + } + + add(label) { + const now = Date.now(); + this.mItems.push({ + label: label, + delta: now - this.mLastTime + }); + this.mDeltaBetweenLastItem = now - this.mInitialTime; + this.mLastTime = now; + } + + addAsync(label, asyncTask) { + const start = Date.now(); + if (typeof asyncTask == 'function') + asyncTask = asyncTask(); + return asyncTask.then(result => { + this.mItems.push({ + label: `(async) ${label}`, + delta: Date.now() - start, + async: true + }); + return result; + }); + } + + toString() { + const logs = this.mItems.map(item => `${item.delta || 0}: ${item.label}`); + return `${this.mName ? this.mName + ': ' : ''}total ${this.mDeltaBetweenLastItem} msec\n${logs.join('\n')}`; + } + + static add(label) { + return MetricsData.$static.add(label); + } + + static addAsync(label, asyncTask) { + return MetricsData.$static.addAsync(label, asyncTask); + } + + static toString() { + return MetricsData.$static.toString(); + } +} + +MetricsData.$static = new MetricsData(); diff --git a/waterfox/browser/components/sidebar/common/TreeItem.js b/waterfox/browser/components/sidebar/common/TreeItem.js new file mode 100644 index 000000000000..aafafb023b67 --- /dev/null +++ b/waterfox/browser/components/sidebar/common/TreeItem.js @@ -0,0 +1,3933 @@ +/* +# 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'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; +import TabFavIconHelper from '/extlib/TabFavIconHelper.js'; + +import { + log as internalLogger, + dumpTab, + mapAndFilter, + mapAndFilterUniq, + toLines, + sanitizeForHTMLText, + sanitizeForRegExpSource, + isNewTabCommandTab, + isFirefoxViewTab, + configs, + doProgressively, +} from './common.js'; + +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from './constants.js'; +import * as ContextualIdentities from './contextual-identities.js'; +import * as SidebarConnection from './sidebar-connection.js'; +import * as TabsStore from './tabs-store.js'; +import * as UniqueId from './unique-id.js'; + +import Window from './Window.js'; + +function log(...args) { + internalLogger('common/TreeItem', ...args); +} + +function successorTabLog(...args) { + internalLogger('background/successor-tab', ...args); +} + + +// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/permissions +// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/tabs/Tab +export const kPERMISSION_ACTIVE_TAB = 'activeTab'; +export const kPERMISSION_TABS = 'tabs'; +export const kPERMISSION_COOKIES = 'cookies'; +export const kPERMISSION_INCOGNITO = 'incognito'; // only for internal use +export const kPERMISSIONS_ALL = new Set([ + kPERMISSION_TABS, + kPERMISSION_COOKIES, + kPERMISSION_INCOGNITO +]); + + +const mOpenedResolvers = new Map(); + +const mIncompletelyTrackedTabs = new Map(); +const mMovingTabs = new Map(); +const mPromisedTrackedTabs = new Map(); + + +browser.windows.onRemoved.addListener(windowId => { + mIncompletelyTrackedTabs.delete(windowId); + mMovingTabs.delete(windowId); +}); + +export class TreeItem { + static TYPE_TAB = 'tab'; + static TYPE_GROUP = 'group'; + static TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER = 'group-collapsed-members-counter'; + + static onElementBound = new EventListenerManager(); + + // The list of properties which should be ignored when synchronization from the + // background to sidebars. + static UNSYNCHRONIZABLE_PROPERTIES = new Set([ + 'id', + // Ignore "index" on synchronization, because it maybe wrong for the sidebar. + // Index of tabs are managed and fixed by other sections like handling of + // "kCOMMAND_NOTIFY_TAB_CREATING", Window.prototype.trackTab, and others. + // See also: https://github.com/piroor/treestyletab/issues/2119 + 'index', + 'reindexedBy' + ]); + + // key = addon ID + // value = Set of states + static autoStickyStates = new Map(); + static allAutoStickyStates = new Set(); + + constructor(raw) { + raw.$TST = this; + this.raw = raw; + this.id = raw.id; + + raw.type = this.type || 'unknown'; + + this.trackedAt = Date.now(); + this.opened = Promise.resolve(true); + + // We should not change the shape of the object, so temporary data should be held in this map. + this.temporaryMetadata = new Map(); + + this.highPriorityTooltipTexts = new Map(); + this.lowPriorityTooltipTexts = new Map(); + + this.$exportedForAPI = null; + this.$exportedForAPIWithPermissions = new Map(); + + this.element = null; + this.classList = null; + this.promisedElement = new Promise((resolve, _reject) => { + this._promisedElementResolver = resolve; + }); + + this.states = new Set(); + this.clear(); + + this.uniqueId = { + id: null, + originalId: null, + originalTabId: null + }; + this.promisedUniqueId = Promise.resolve(null); + } + + destroy() { + if (this.element && + this.element.parentNode) + this.element.parentNode.removeChild(this.element); + this.unbindElement(); + // this.raw.$TST = null; // raw.$TST is used by destruction processes. + this.raw = null; + this.promisedUniqueId = null; + this.uniqueId = null; + this.destroyed = true; + } + + clear() { + this.states.clear(); + this.attributes = {}; + } + + bindElement(element) { + element.$TST = this; + element.apiRaw = this.raw; + this.element = element; + this.classList = element.classList; + // wait until initialization processes are completed + (Constants.IS_BACKGROUND ? + setTimeout : // because window.requestAnimationFrame is decelerate for an invisible document. + window.requestAnimationFrame)(() => { + this._promisedElementResolver(element); + if (!element) { // reset for the next binding + this.promisedElement = new Promise((resolve, _reject) => { + this._promisedElementResolver = resolve; + }); + } + if (!this.raw) // unbound while waiting! + return; + TreeItem.onElementBound.dispatch(this.raw); + }, 0); + } + + unbindElement() { + if (this.element) { + for (const state of this.states) { + this.element.classList.remove(state); + if (state == Constants.kTAB_STATE_HIGHLIGHTED) + this.element.removeAttribute('aria-selected'); + } + for (const name of Object.keys(this.attributes)) { + this.element.removeAttribute(name); + } + this.element.$TST = null; + this.element.apiRaw = null; + } + this.element = null; + this.classList = null; + } + + startMoving() { + return Promise.resolve(); + } + + updateUniqueId(_options = {}) { + return Promise.resolve(null); + } + + get type() { + return null; + } + + get renderingId() { + return `${this.type}:${this.id}`; + } + + get title() { + return this.raw.title; + } + + //=================================================================== + // status of tree item + //=================================================================== + + get collapsed() { + return this.states.has(Constants.kTAB_STATE_COLLAPSED); + } + + get collapsedCompletely() { + return this.states.has(Constants.kTAB_STATE_COLLAPSED_DONE); + } + + get subtreeCollapsed() { + return this.states.has(Constants.kTAB_STATE_SUBTREE_COLLAPSED); + } + + get isSubtreeCollapsable() { + return this.hasChild && + !this.collapsed && + !this.subtreeCollapsed; + } + + get isAutoExpandable() { + return this.hasChild && this.subtreeCollapsed; + } + + get duplicating() { + return this.states.has(Constants.kTAB_STATE_DUPLICATING); + } + + get removing() { + return this.states.has(Constants.kTAB_STATE_REMOVING); + } + + get sticky() { + return this.states.has(Constants.kTAB_STATE_STICKY); + } + + get stuck() { + return this.element?.parentNode?.classList.contains('sticky-tabs-container'); + } + + get canBecomeSticky() { + if (this.collapsed || + this.states.has(Constants.kTAB_STATE_EXPANDING) || + this.states.has(Constants.kTAB_STATE_COLLAPSING)) + return false; + + if (this.sticky) + return true; + + if ((new Set([...this.states, ...TreeItem.allAutoStickyStates])).size < this.states.size + TreeItem.allAutoStickyStates.size) { + return true; + } + + return false; + } + + get promisedPossibleOpenerBookmarks() { + return Promise.resolve(null); + } + + get defaultTooltipText() { + return this.raw.title; + } + + get tooltipTextWithDescendants() { + const tooltip = [`* ${this.defaultTooltipText}`]; + for (const child of this.children) { + if (!child) + continue; + tooltip.push(child.$TST.tooltipTextWithDescendants.replace(/^/gm, ' ')); + } + return tooltip.join('\n'); + } + + get tooltipHtml() { + return `${sanitizeForHTMLText(this.raw.title)}`; + } + + get tooltipHtmlWithDescendants() { + return `
    ${this.generateTooltipHtmlWithDescendants()}
`; + } + generateTooltipHtmlWithDescendants() { + let tooltip = `
  • ${this.tooltipHtml}`; + const children = []; + for (const child of this.children) { + if (!child) + continue; + children.push(child.$TST.generateTooltipHtmlWithDescendants()); + } + if (children.length > 0) + tooltip += `
      ${children.join('')}
    `; + return `${tooltip}
  • `; + } + + registerTooltipText(ownerId, text, isHighPriority = false) { + if (isHighPriority) { + this.highPriorityTooltipTexts.set(ownerId, text); + this.lowPriorityTooltipTexts.delete(ownerId); + } + else { + this.highPriorityTooltipTexts.delete(ownerId); + this.lowPriorityTooltipTexts.set(ownerId, text); + } + } + + unregisterTooltipText(ownerId) { + this.highPriorityTooltipTexts.delete(ownerId); + this.lowPriorityTooltipTexts.delete(ownerId); + } + + get highPriorityTooltipText() { + if (this.highPriorityTooltipTexts.size == 0) + return null; + return [...this.highPriorityTooltipTexts.values()][this.highPriorityTooltipTexts.size - 1]; + } + + get lowPriorityTooltipText() { + if (this.lowPriorityTooltipTexts.size == 0) + return null; + return [...this.lowPriorityTooltipTexts.values()][this.lowPriorityTooltipTexts.size - 1]; + } + + //=================================================================== + // neighbor tabs + //=================================================================== + + get nextTab() { return null; } + get previousTab() { return null; } + get unsafeNextTab() { return null; } + get unsafePreviousTab() { return null; } + get nearestCompletelyOpenedNormalFollowingTab() { return null; } + get nearestCompletelyOpenedNormalPrecedingTab() { return null; } + get nearestVisibleFollowingTab() { return null; } + get unsafeNearestExpandedFollowingTab() { return null; } + get nearestVisiblePrecedingTab() { return null; } + get unsafeNearestExpandedPrecedingTab() { return null; } + get nearestLoadedTab() { return null; } + get nearestLoadedTabInTree() { return null; } + get nearestLoadedSiblingTab() { return null; } + get nearestSameTypeRenderedTab() { return null; } + + //=================================================================== + // tree relations + //=================================================================== + + get parent() { return null; } + get hasParent() { return false; } + + get ancestorIds() { return []; } + get ancestors() { return []; } + + get level() { return 0; } + + get rootTab() { return null; } + + get topmostSubtreeCollapsedAncestor() { return null; } + + get nearestVisibleAncestorOrSelf() { return null; } + + get nearestFollowingRootTab() { return null; } + + get nearestFollowingForeignerTab() { + const base = this.lastDescendant || this.raw; + return base?.$TST.nextTab; + } + + get unsafeNearestFollowingForeignerTab() { + const base = this.lastDescendant || this.raw; + return base?.$TST.unsafeNextTab; + } + + get children() { return []; } + + get firstChild() { + const children = this.children; + return children.length > 0 ? children[0] : null ; + } + + get firstVisibleChild() { + const firstChild = this.firstChild; + return firstChild && !firstChild.$TST.collapsed && !firstChild.hidden && firstChild; + } + + get lastChild() { + const children = this.children; + return children.length > 0 ? children[children.length - 1] : null ; + } + + get hasChild() { return false; } + + get descendants() { return []; } + + get lastDescendant() { + const descendants = this.descendants; + return descendants.length ? descendants[descendants.length-1] : null ; + } + + get nextSiblingTab() { return null; } + + get nextVisibleSiblingTab() { + const nextSibling = this.nextSiblingTab; + return nextSibling && !nextSibling.$TST.collapsed && !nextSibling.hidden && nextSibling; + } + + get previousSiblingTab() { return null; } + + get needToBeGroupedSiblings() { return []; } + + //=================================================================== + // other relations + //=================================================================== + + findSuccessor(_options = {}) { + return null; + } + + // if all items are aldeardy placed at there, we don't need to move them. + isAllPlacedBeforeSelf(items) { + if (!this.raw || + items.length == 0) + return true; + let nextItem = this.raw; + if (items[items.length - 1] == nextItem) + nextItem = nextItem.$TST.unsafeNextTab; + if (!nextItem && !items[items.length - 1].$TST.unsafeNextTab) + return true; + + items = Array.from(items); + let previousItem = items.shift(); + for (const item of items) { + if (item.$TST.unsafePreviousTab != previousItem) + return false; + previousItem = item; + } + return !nextItem || + !previousItem || + previousItem.$TST.unsafeNextTab == nextItem; + } + + isAllPlacedAfterSelf(items) { + if (!this.raw || + items.length == 0) + return true; + let previousItem = this.raw; + if (items[0] == previousItem) + previousItem = previousItem.$TST.unsafePreviousTab; + if (!previousItem && !items[0].$TST.unsafePreviousTab) + return true; + + items = Array.from(items).reverse(); + let nextItem = items.shift(); + for (const item of items) { + if (item.$TST.unsafeNextTab != nextItem) + return false; + nextItem = item; + } + return !previousItem || + !nextItem || + nextItem.$TST.unsafePreviousTab == previousItem; + } + + detach() {} + + //=================================================================== + // State + //=================================================================== + + async toggleState(state, condition, { permanently, toTab, broadcast } = {}) { + if (condition) + return this.addState(state, { permanently, toTab, broadcast }); + else + return this.removeState(state, { permanently, toTab, broadcast }); + } + + async addState(state) { + state = state && String(state) || undefined; + if (!this.raw || !state) + return; + + if (this.classList) { + this.classList.add(state); + } + if (this.states) { + this.states.add(state); + } + } + + async removeState(state) { + state = state && String(state) || undefined; + if (!this.raw || !state) + return; + + if (this.classList) { + this.classList.remove(state); + } + if (this.states) { + this.states.delete(state); + } + } + + async getPermanentStates() { + return Promise.resolve([]); + } + + inheritSoundStateFromChildren() {} + + inheritSharingStateFromChildren() {} + + onNativeGroupModified() {} + + setAttribute(attribute, value) { + if (this.element) + this.element.setAttribute(attribute, value); + this.attributes[attribute] = value; + } + + getAttribute(attribute) { + return this.attributes[attribute]; + } + + removeAttribute(attribute) { + if (this.element) + this.element.removeAttribute(attribute); + delete this.attributes[attribute]; + } + + resolveOpened() {} + rejectOpened() {} + + memorizeNeighbors(hint) { + if (!this.raw) // already closed tab + return; + log(`memorizeNeighbors ${this.raw.id} as ${hint}`); + this.lastPreviousTabId = this.unsafePreviousTab?.id; + this.lastNextTabId = this.unsafeNextTab?.id; + } + + // https://github.com/piroor/treestyletab/issues/2309#issuecomment-518583824 + get movedInBulk() { + const previousTab = this.unsafePreviousTab; + if (this.lastPreviousTabId && + this.lastPreviousTabId != previousTab?.id) { + log(`not bulkMoved lastPreviousTabId=${this.lastNextTabId}, previousTab=${previousTab?.id}`); + return false; + } + + const nextTab = this.unsafeNextTab; + if (this.lastNextTabId && + this.lastNextTabId != nextTab?.id) { + log(`not bulkMoved lastNextTabId=${this.lastNextTabId}, nextTab=${nextTab?.id}`); + return false; + } + + return true; + } + + get sanitized() { + if (!this.raw) + return {}; + + const sanitized = { + ...this.raw, + '$possibleInitialUrl': null, + '$TST': null, + '$exportedForAPI': null, + '$exportedForAPIWithPermissions': null, + }; + delete sanitized.$TST; + return sanitized; + } + + export(full) { + const exported = { + id: this.id, + uniqueId: this.uniqueId, + states: Array.from(this.states), + attributes: this.attributes, + parentId: this.parentId, + childIds: this.childIds, + collapsed: this.collapsed, + subtreeCollapsed: this.subtreeCollapsed + }; + if (full) + return { + ...this.sanitized, + $TST: exported + }; + return exported; + } + + apply(exported) { + this.raw.title = exported.title; + } + + // This function is complex a little, but we should not make a custom class for this purpose, + // bacause instances of the class will be very short-life and increases RAM usage on + // massive tabs case. + async exportForAPI({ addonId, light, isContextTab, interval, permissions, cache, cacheKey } = {}) { + const permissionsKey = [...permissions].sort().join(','); + if (!light && + configs.cacheAPITreeItems && + this.$exportedForAPIWithPermissions.has(permissionsKey)) + return this.$exportedForAPIWithPermissions.get(permissionsKey); + + let exportedTreeItem = configs.cacheAPITreeItems && light ? this.$exportedForAPI : null; + if (!exportedTreeItem) { + const children = await doProgressively( + this.raw.$TST.children, + child => child.$TST.exportForAPI({ addonId, light, isContextTab, interval, permissions, cache, cacheKey }), + interval + ); + + const tabStates = this.raw.$TST.states; + exportedTreeItem = { + id: this.raw.id, + windowId: this.raw.windowId, + type: this.type, + states: tabStates && tabStates.size > 0 &&Constants.kTAB_SAFE_STATES_ARRAY.filter(state => tabStates.has(state)) || [], + indent: parseInt(this.raw.$TST.getAttribute(Constants.kLEVEL) || 0), + children, + ancestorTabIds: this.raw.$TST.ancestorIds || [], + bundledTabId: this.raw.$TST.bundledTabId, + }; + if (this.stuck) + exportedTreeItem.states.push(Constants.kTAB_STATE_STUCK); + if (configs.cacheAPITreeItems && light) + this.$exportedForAPI = exportedTreeItem; + } + + if (light) + return exportedTreeItem; + + const fullExportedTreeItem = { ...exportedTreeItem }; + + await this.exportFullTreeItemProperties(fullExportedTreeItem, { isContextTab, interval, permissions, cache }); + + if (configs.cacheAPITreeItems) + this.$exportedForAPIWithPermissions.set(permissionsKey, fullExportedTreeItem) + return fullExportedTreeItem; + } + + exportFullTreeItemProperties() {} + + invalidateCache() { + this.$exportedForAPI = null; + this.$exportedForAPIWithPermissions.clear(); + } + + applyStatesToElement() { + if (!this.element) + return; + + this.applyAttributesToElement(); + + for (const state of this.states) { + this.element.classList.add(state); + } + + for (const [name, value] of Object.entries(this.attributes)) { + this.element.setAttribute(name, value); + } + } + + applyAttributesToElement() { + if (!this.element) + return; + + this.element.applyAttributes(); + } + + /* element utilities */ + + invalidateElement(targets) { + if (this.element?.invalidate) + this.element.invalidate(targets); + } + + updateElement(targets) { + if (this.element?.update) + this.element.update(targets); + } + + + //=================================================================== + // class methods + //=================================================================== + + static registerAutoStickyState(providerId, statesToAdd) { + if (!statesToAdd) { + statesToAdd = providerId; + providerId = browser.runtime.id; + } + const states = TreeItem.autoStickyStates.get(providerId) || new Set(); + if (!Array.isArray(statesToAdd)) + statesToAdd = [statesToAdd]; + for (const state of statesToAdd) { + states.add(state) + } + if (states.size == 0) + return; + + TreeItem.autoStickyStates.set(providerId, states); + for (const state of states) { + TreeItem.allAutoStickyStates.add(state); + } + + TreeItem.updateCanBecomeStickyTabsIndex(TabsStore.getCurrentWindowId()); + + if (Constants.IS_BACKGROUND) { + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_BROADCAST_TAB_AUTO_STICKY_STATE, + providerId, + add: [...statesToAdd], + }); + } + } + + static unregisterAutoStickyState(providerId, statesToRemove) { + if (!statesToRemove) { + statesToRemove = providerId; + providerId = browser.runtime.id; + } + const states = TreeItem.autoStickyStates.get(providerId); + if (!states) + return; + if (!Array.isArray(statesToRemove)) + statesToRemove = [statesToRemove]; + for (const state of statesToRemove) { + states.delete(state) + } + if (states.size > 0) + TreeItem.autoStickyStates.set(providerId, states); + else + TreeItem.autoStickyStates.delete(providerId); + + TreeItem.allAutoStickyStates = new Set([ + ...TreeItem.autoStickyStates.values(), + ].flat()); + + TreeItem.updateCanBecomeStickyTabsIndex(TabsStore.getCurrentWindowId()); + + if (Constants.IS_BACKGROUND) { + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_BROADCAST_TAB_AUTO_STICKY_STATE, + providerId, + remove: [...statesToRemove], + }); + } + } + + static async updateCanBecomeStickyTabsIndex(windowId) { + const tabs = await (windowId ? browser.tabs.query({ windowId }) : browser.tabs.query({})); + for (const tab of tabs) { + const item = TreeItem.get(tab); + if (!item) { + continue; + } + if (item.$TST.canBecomeSticky) + TabsStore.addCanBecomeStickyTab(item); + else + TabsStore.removeCanBecomeStickyTab(item); + } + } + + static uniqTabsAndDescendantsSet(tabs) { + if (!Array.isArray(tabs)) + tabs = [tabs]; + return Array.from(new Set(tabs.map(tab => [tab].concat(tab.$TST.descendants)).flat())).sort(TreeItem.compare); + } + + static compare(a, b) { + const delta = a.index - b.index; + if (delta == 0) { + return (a.type == TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER) ? 1 : + (a.type == TreeItem.TYPE_GROUP || !!a.color) ? -1 : + 1; + } + return delta; + } + + static sort(tabs) { + return tabs.length == 0 ? tabs : tabs.sort(TreeItem.compare); + } +} + + +export class TabGroupCollapsedMembersCounter extends TreeItem { + constructor(raw) { + super(raw); + + raw.type = TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER; + + this.reindex(); + } + + destroy() { + super.destroy(); + + this.raw.group = null; + } + + get type() { + return TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER; + } + + reindex(maybeLastMember) { + const lastMember = this.raw.group.$TST.lastMember || maybeLastMember; + if (lastMember) { + this.raw.index = lastMember.index; + } + } + + get group() { + return this.raw.group; + } + + get nativeTabGroup() { + return this.raw.group; + } + + update() { + this.raw.color = this.raw.group.color; + this.raw.windowId = this.raw.group.windowId; + this.reindex(); + } + + get title() { + const collapsedItemsCount = Math.max(0, this.raw.group.$TST.members.length - 1); + return `+${collapsedItemsCount}`; + } + + get sanitized() { + if (!this.raw) + return {}; + + const sanitized = { + ...super.sanitized, + group: this.raw.group.$TST.sanitized, + }; + return sanitized; + } + + export(full) { + const exported = super.export(full); + exported.group = this.raw.group.$TST.export(full); + if (full) + return { + ...this.sanitized, + $TST: exported + }; + return exported; + } +} + + +export class TabGroup extends TreeItem { + constructor(raw) { + super(raw); + + TabsStore.tabGroups.set(raw.id, raw); + TabsStore.windows.get(raw.windowId)?.tabGroups.set(raw.id, raw); + + this.reindex(); + } + + destroy() { + const win = TabsStore.windows.get(this.raw.windowId); + if (win) { + win.tabGroups.delete(this.id); + } + + TabsStore.tabGroups.delete(this.id); + + if (this._collapsedMembersCounterItem) { + this._collapsedMembersCounterItem.destroy(); + this._collapsedMembersCounterItem = null; + } + + super.destroy(); + } + + get type() { + return TreeItem.TYPE_GROUP; + } + + get group() { + return this.raw; + } + + get nearestVisibleAncestorOrSelf() { + return this.raw; + } + + get members() { + return TabGroup.getMembers(this.raw.id); + } + + get firstMember() { + return TabGroup.getFirstMember(this.raw.id); + } + + get lastMember() { + return TabGroup.getLastMember(this.raw.id); + } + + get children() { + return this.members.filter(tab => !tab.$TST.parentId); + } + + get hasChild() { + return !!this.firstMember; + } + + get descendants() { + return this.members; + } + + reindex(maybeFirstMember) { + const firstMember = TabGroup.getFirstMember(this.raw.id) || maybeFirstMember; + if (firstMember) { + this.raw.index = firstMember.index; + } + } + + apply(exported) { + super.apply(exported); + this.raw.color = exported.color; + this.raw.collapsed = exported.collapsed; + } + + get createParams() { + return { + title: this.raw.title, + color: this.raw.color, + collapsed: this.raw.collapsed, + windowId: this.raw.windowId, + }; + } + + get collapsedMembersCounterItem() { + if (this._collapsedMembersCounterItem) { + return this._collapsedMembersCounterItem; + } + this._collapsedMembersCounterItem = { + id: this.raw.id, + windowId: this.raw.windowId, + color: this.raw.color, + type: TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER, + group: this.raw, + }; + new TabGroupCollapsedMembersCounter(this._collapsedMembersCounterItem); + return this._collapsedMembersCounterItem; + } + + + //=================================================================== + // class methods + //=================================================================== + + static get(groupId) { + return TabsStore.tabGroups.get(groupId); + } + + static init(group) { + if (group.$TST instanceof TabGroup) { + return group; + } + if ('index' in group) { + group.index = -1; + } + if ('incognito' in group) { + group.incognito = false; + } + group.$TST = new TabGroup(group); + return group; + } + + static getMembers(groupId, options = {}) { + const windowId = TabGroup.get(groupId)?.windowId || TabsStore.getCurrentWindowId(); + return TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.nativelyGroupedTabsInWindow, windowId), + living: true, + groupId, + ordered: true, + ...options + }); + } + + static getFirstMember(groupId, options = {}) { + const windowId = TabGroup.get(groupId)?.windowId || TabsStore.getCurrentWindowId(); + return TabsStore.query({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.nativelyGroupedTabsInWindow, windowId), + living: true, + groupId, + ...options, + ordered: true, + first: true, + }); + } + + static getLastMember(groupId, options = {}) { + const windowId = TabGroup.get(groupId)?.windowId || TabsStore.getCurrentWindowId(); + return TabsStore.query({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.nativelyGroupedTabsInWindow, windowId), + living: true, + groupId, + ...options, + ordered: true, + last: true, + }); + } + + // https://searchfox.org/mozilla-central/rev/578d9c83f046d8c361ac6b98b297c27990d468fd/browser/components/tabbrowser/content/tabgroup-menu.js#25 + static COLORS = [ + 'blue', + 'purple', + 'cyan', + 'orange', + 'yellow', + 'pink', + 'green', + 'gray', + 'red', + ]; + + static getNextUnusedColor(windowId = null) { + if (!windowId) { + windowId = TabsStore.getCurrentWindowId(); + } + const unusedColors = new Set(TabGroup.COLORS); + for (const group of TabsStore.windows.get(windowId).tabGroups.values()) { + unusedColors.delete(group.color); + } + if (unusedColors.size > 0) { + return [...unusedColors][0]; + } + // all colors are used + const index = Math.floor(Math.random() * TabGroup.COLORS.length); + return TabGroup.COLORS[index]; + } +} + + +export class Tab extends TreeItem { + //=================================================================== + // tab tracking events + //=================================================================== + + static onTracked = new EventListenerManager(); + static onDestroyed = new EventListenerManager(); + static onInitialized = new EventListenerManager(); + + //=================================================================== + // general tab events + //=================================================================== + + static onGroupTabDetected = new EventListenerManager(); + static onLabelUpdated = new EventListenerManager(); + static onStateChanged = new EventListenerManager(); + static onPinned = new EventListenerManager(); + static onUnpinned = new EventListenerManager(); + static onHidden = new EventListenerManager(); + static onShown = new EventListenerManager(); + static onTabInternallyMoved = new EventListenerManager(); + static onCollapsedStateChanged = new EventListenerManager(); + static onMutedStateChanged = new EventListenerManager(); + static onAutoplayBlockedStateChanged = new EventListenerManager(); + static onSharingStateChanged = new EventListenerManager(); + + static onBeforeCreate = new EventListenerManager(); + static onCreating = new EventListenerManager(); + static onCreated = new EventListenerManager(); + static onRemoving = new EventListenerManager(); + static onRemoved = new EventListenerManager(); + static onMoving = new EventListenerManager(); + static onMoved = new EventListenerManager(); + static onActivating = new EventListenerManager(); + static onActivated = new EventListenerManager(); + static onUnactivated = new EventListenerManager(); + static onUpdated = new EventListenerManager(); + static onRestored = new EventListenerManager(); + static onWindowRestoring = new EventListenerManager(); + static onAttached = new EventListenerManager(); + static onDetached = new EventListenerManager(); + + static onMultipleTabsRemoving = new EventListenerManager(); + static onMultipleTabsRemoved = new EventListenerManager(); + static onChangeMultipleTabsRestorability = new EventListenerManager(); + static onStateChanged = new EventListenerManager(); + static onNativeGroupModified = new EventListenerManager(); + + + constructor(raw) { + const alreadyTracked = Tab.get(raw.id); + if (alreadyTracked) + return alreadyTracked.$TST; + + log(`tab ${dumpTab(raw)} is newly tracked: `, raw); + + super(raw); + + this.promisedUniqueId = new Promise((resolve, _reject) => { + this.onUniqueIdGenerated = resolve; + }); + + this.index = raw.index; + + this.updatingOpenerTabIds = []; // this must be an array, because same opener tab id can appear multiple times. + + this.newRelatedTabsCount = 0; + + this.lastSoundStateCounts = { + soundPlaying: 0, + muted: 0, + autoPlayBlocked: 0, + }; + this.soundPlayingChildrenIds = new Set(); + this.maybeSoundPlayingChildrenIds = new Set(); + this.mutedChildrenIds = new Set(); + this.maybeMutedChildrenIds = new Set(); + this.autoplayBlockedChildrenIds = new Set(); + this.maybeAutoplayBlockedChildrenIds = new Set(); + + this.lastSharingStateCounts = { + camera: 0, + microphone: 0, + screen: 0, + }; + this.sharingCameraChildrenIds = new Set(); + this.maybeSharingCameraChildrenIds = new Set(); + this.sharingMicrophoneChildrenIds = new Set(); + this.maybeSharingMicrophoneChildrenIds = new Set(); + this.sharingScreenChildrenIds = new Set(); + this.maybeSharingScreenChildrenIds = new Set(); + + this.opened = new Promise((resolve, reject) => { + const resolvers = mOpenedResolvers.get(raw.id) || new Set(); + resolvers.add({ resolve, reject }); + mOpenedResolvers.set(raw.id, resolvers); + }); + + TabsStore.tabs.set(raw.id, raw); + + const win = TabsStore.windows.get(raw.windowId) || new Window(raw.windowId); + win.trackTab(raw); + + // Don't update indexes here, instead Window.prototype.trackTab() + // updates indexes because indexes are bound to windows. + // TabsStore.updateIndexesForTab(raw); + + if (raw.active) { + TabsStore.activeTabInWindow.set(raw.windowId, raw); + TabsStore.activeTabsInWindow.get(raw.windowId).add(raw); + } + else { + TabsStore.activeTabsInWindow.get(raw.windowId).delete(raw); + } + setTimeout(() => { + if (!TabsStore.ensureLivingItem(raw)) { + return; + } + if (raw.active) { + Tab.onActivated.dispatch(raw); + } + else { + Tab.onUnactivated.dispatch(raw); + } + }, 0); + + const incompletelyTrackedTabsPerWindow = mIncompletelyTrackedTabs.get(raw.windowId) || new Set(); + incompletelyTrackedTabsPerWindow.add(raw); + mIncompletelyTrackedTabs.set(raw.windowId, incompletelyTrackedTabsPerWindow); + this.promisedUniqueId.then(() => { + incompletelyTrackedTabsPerWindow.delete(raw); + Tab.onTracked.dispatch(raw); + }); + + // We should initialize private properties with blank value for better performance with a fixed shape. + this.delayedInheritSoundStateFromChildren = null; + } + + destroy() { + mPromisedTrackedTabs.delete(`${this.id}:true`); + mPromisedTrackedTabs.delete(`${this.id}:false`); + + Tab.onDestroyed.dispatch(this.raw); + this.detach(); + + if (this.temporaryMetadata.has('reservedCleanupNeedlessGroupTab')) { + clearTimeout(this.temporaryMetadata.get('reservedCleanupNeedlessGroupTab')); + this.temporaryMetadata.delete('reservedCleanupNeedlessGroupTab'); + } + + TabsStore.tabs.delete(this.id); + if (this.uniqueId) + TabsStore.tabsByUniqueId.delete(this.uniqueId.id); + + TabsStore.removeTabFromIndexes(this.raw); + + super.destroy(); + } + + clear() { + super.clear(); + + this.parentId = null; + this.childIds = []; + this.cachedAncestorIds = null; + this.cachedDescendantIds = null; + } + + startMoving() { + let onTabMoved; + const promisedMoved = new Promise((resolve, _reject) => { + onTabMoved = resolve; + }); + const movingTabs = mMovingTabs.get(this.raw.windowId) || new Set(); + movingTabs.add(promisedMoved); + mMovingTabs.set(this.raw.windowId, movingTabs); + promisedMoved.then(() => { + movingTabs.delete(promisedMoved); + }); + return onTabMoved; + } + + updateUniqueId(options = {}) { + if (!this.raw) { + const error = new Error('FATAL ERROR: updateUniqueId() is unavailable for an invalid tab'); + console.log(error); + throw error; + } + if (options.id) { + if (this.uniqueId.id) + TabsStore.tabsByUniqueId.delete(this.uniqueId.id); + this.uniqueId.id = options.id; + TabsStore.tabsByUniqueId.set(options.id, this.raw); + this.setAttribute(Constants.kPERSISTENT_ID, options.id); + return Promise.resolve(this.uniqueId); + } + return UniqueId.request(this.raw, options).then(uniqueId => { + if (uniqueId && TabsStore.ensureLivingItem(this.raw)) { // possibly removed from document while waiting + this.uniqueId = uniqueId; + TabsStore.tabsByUniqueId.set(uniqueId.id, this.raw); + this.setAttribute(Constants.kPERSISTENT_ID, uniqueId.id); + } + return uniqueId || {}; + }).catch(error => { + console.log(`FATAL ERROR: Failed to get unique id for a tab ${this.id}: `, error); + return {}; + }); + } + + get type() { + return TreeItem.TYPE_TAB; + } + + get tab() { + return this.raw; + } + + get nativeTabGroup() { + if (this.raw.groupId == -1) { + return null; + } + return TabGroup.get(this.raw.groupId); + } + + //=================================================================== + // status of tab + //=================================================================== + + get soundPlaying() { + return !!(this.raw?.audible && !this.raw?.mutedInfo.muted); + } + get maybeSoundPlaying() { + return (this.soundPlaying || + (this.states.has(Constants.kTAB_STATE_HAS_SOUND_PLAYING_MEMBER) && + this.hasChild)); + } + + get muted() { + return !!(this.raw?.mutedInfo?.muted); + } + get maybeMuted() { + return (this.muted || + (this.states.has(Constants.kTAB_STATE_HAS_MUTED_MEMBER) && + this.hasChild)); + } + + get autoplayBlocked() { + return this.states.has(Constants.kTAB_STATE_AUTOPLAY_BLOCKED); + } + get maybeAutoplayBlocked() { + return (this.autoplayBlocked || + (this.states.has(Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER) && + this.hasChild)); + } + + get sharingCamera() { + return !!(this.raw?.sharingState?.camera); + } + get maybeSharingCamera() { + return (this.sharingCamera || + (this.states.has(Constants.kTAB_STATE_HAS_SHARING_CAMERA_MEMBER) && + this.hasChild)); + } + + get sharingMicrophone() { + return !!(this.raw?.sharingState?.microphone); + } + get maybeSharingMicrophone() { + return (this.sharingMicrophone || + (this.states.has(Constants.kTAB_STATE_HAS_SHARING_MICROPHONE_MEMBER) && + this.hasChild)); + } + + get sharingScreen() { + return !!(this.raw?.sharingState?.screen); + } + get maybeSharingScreen() { + return (this.sharingScreen || + (this.states.has(Constants.kTAB_STATE_HAS_SHARING_SCREEN_MEMBER) && + this.hasChild)); + } + + get precedesPinnedTab() { + const following = this.nearestVisibleFollowingTab; + return following?.pinned; + } + + get followsUnpinnedTab() { + const preceding = this.nearestVisiblePrecedingTab; + return preceding && !preceding.pinned; + } + + get isNewTabCommandTab() { + if (!this.raw || + !configs.guessNewOrphanTabAsOpenedByNewTabCommand) + return false; + + if (this.raw.$isNewTabCommandTab) + return true; + + // Firefox sets "New Tab" title to a new tab command tab, even if + // "Blank Page" is chosen as the new tab page. So we can detect the case + // safely here. + // (confirmed on Firefox 124) + if (isNewTabCommandTab(this.raw)) + return true; + + // Firefox always opens a blank tab as the placeholder, when trying to + // open a bookmark in a new tab. So, we cannot determine the tab is + // "really opened as a new blank tab" or "just as a placeholder for an + // Open in New Tab operation", when the user choose the "Blank Page" + // as the new tab page and the new tab page is opened without the title + // "New Tab" due to any reason. + // But, when "Blank Page" is chosen as the new tab page, Firefox loads + // "about:blank" into a newly opened blank tab. As the result both current + // URL and the previous URL become "about:blank". This is an important + // difference between "a new blank tab" and "a blank tab opened for an + // Open in New Tab command". + // (confirmed on Firefox 124) + if (this.raw.url == 'about:blank' && + this.raw.previousUrl != 'about:blank') + return false; + + return false; + } + + get isGroupTab() { + return this.states.has(Constants.kTAB_STATE_GROUP_TAB) || + this.hasGroupTabURL; + } + + get hasGroupTabURL() { + return !!(this.raw?.url?.indexOf(Constants.kGROUP_TAB_URI) == 0); + } + + get isTemporaryGroupTab() { + if (!this.raw || !this.isGroupTab) + return false; + return (new URL(this.raw.url)).searchParams.get('temporary') == 'true'; + } + + get isTemporaryAggressiveGroupTab() { + if (!this.raw || !this.isGroupTab) + return false; + return (new URL(this.raw.url)).searchParams.get('temporaryAggressive') == 'true'; + } + + get replacedParentGroupTabCount() { + if (!this.raw || !this.isGroupTab) + return 0; + const count = parseInt((new URL(this.raw.url)).searchParams.get('replacedParentCount')); + return isNaN(count) ? 0 : count; + } + + // Firefox Multi-Account Containers + // https://addons.mozilla.org/firefox/addon/multi-account-containers/ + // Temporary Containers + // https://addons.mozilla.org/firefox/addon/temporary-containers/ + get mayBeReplacedWithContainer() { + return !!( + this.$possiblePredecessorPreviousTab || + this.$possiblePredecessorNextTab + ); + } + get $possiblePredecessorPreviousTab() { + const prevTab = this.unsafePreviousTab; + return ( + prevTab && + this.raw && + this.raw.cookieStoreId != prevTab.cookieStoreId && + this.raw.url == prevTab.url + ) ? prevTab : null; + } + get $possiblePredecessorNextTab() { + const nextTab = this.unsafeNextTab; + return ( + nextTab && + this.raw && + this.raw.cookieStoreId != nextTab.cookieStoreId && + this.raw.url == nextTab.url + ) ? nextTab : null; + } + get possibleSuccessorWithDifferentContainer() { + const firstChild = this.firstChild; + const nextTab = this.nextTab; + const prevTab = this.previousTab; + return ( + (firstChild && + firstChild.$TST.$possiblePredecessorPreviousTab == this.raw && + firstChild) || + (nextTab && + !nextTab.$TST.temporaryMetadata.has('openedCompletely') && + nextTab.$TST.$possiblePredecessorPreviousTab == this.raw && + nextTab) || + (prevTab && + !prevTab.$TST.temporaryMetadata.has('openedCompletely') && + prevTab.$TST.$possiblePredecessorNextTab == this.raw && + prevTab) + ); + } + + get selected() { + return this.states.has(Constants.kTAB_STATE_SELECTED) || + (this.hasOtherHighlighted && !!(this.raw?.highlighted)); + } + + get multiselected() { + return this.raw && + this.selected && + (this.hasOtherHighlighted || + TabsStore.selectedTabsInWindow.get(this.raw.windowId).size > 1); + } + + get hasOtherHighlighted() { + const highlightedTabs = this.raw && TabsStore.highlightedTabsInWindow.get(this.raw.windowId); + return !!(highlightedTabs && highlightedTabs.size > 1); + } + + get canBecomeSticky() { + if (this.raw?.pinned) { + return false; + } + return super.canBecomeSticky; + } + + get promisedPossibleOpenerBookmarks() { + if ('possibleOpenerBookmarks' in this) + return Promise.resolve(this.possibleOpenerBookmarks); + return new Promise(async (resolve, _reject) => { + if (!browser.bookmarks || !this.raw) + return resolve(this.possibleOpenerBookmarks = []); + // A new tab from bookmark is opened with a title: its URL without the scheme part. + const url = this.raw.$possibleInitialUrl; + try { + const possibleBookmarks = await Promise.all([ + this._safeSearchBookmstksWithUrl(`http://${url}`), + this._safeSearchBookmstksWithUrl(`http://www.${url}`), + this._safeSearchBookmstksWithUrl(`https://${url}`), + this._safeSearchBookmstksWithUrl(`https://www.${url}`), + this._safeSearchBookmstksWithUrl(`ftp://${url}`), + this._safeSearchBookmstksWithUrl(`moz-extension://${url}`), + this._safeSearchBookmstksWithUrl(url), // about:* and so on + ]); + log(`promisedPossibleOpenerBookmarks for tab ${this.id} (${url}): `, possibleBookmarks); + resolve(this.possibleOpenerBookmarks = possibleBookmarks.flat()); + } + catch(error) { + log(`promisedPossibleOpenerBookmarks for the tab {this.id} (${url}): `, error); + // If it is detected as "not a valid URL", then + // it cannot be a tab opened from a bookmark. + resolve(this.possibleOpenerBookmarks = []); + } + }); + } + async _safeSearchBookmstksWithUrl(url) { + try { + return await browser.bookmarks.search({ url }); + } + catch(error) { + log(`_searchBookmstksWithUrl failed: tab ${this.id} (${url}): `, error); + try { + // bookmarks.search() does not accept "moz-extension:" URL + // via a queyr with "url" on Firefox 105 and later - it raises an error as + // "Uncaught Error: Type error for parameter query (Value must either: + // be a string value, or .url must match the format "url") for bookmarks.search." + // Thus we use a query with "query" to avoid the error. + // See also: https://github.com/piroor/treestyletab/issues/3203 + // https://bugzilla.mozilla.org/show_bug.cgi?id=1791313 + const bookmarks = await browser.bookmarks.search({ query: url }).catch(_error => []); + return bookmarks.filter(bookmark => bookmark.url == url); + } + catch(_error) { + return []; + } + } + } + + get cookieStoreName() { + const identity = this.raw?.cookieStoreId && ContextualIdentities.get(this.raw.cookieStoreId); + return identity ? identity.name : null; + } + + get defaultTooltipText() { + return this.cookieStoreName ? `${this.raw.title} - ${this.cookieStoreName}` : super.defaultTooltipText; + } + + get tooltipHtml() { + return this.cookieStoreName ? + `${sanitizeForHTMLText(this.raw.title)}${sanitizeForHTMLText(this.cookieStoreName)}` : + super.tooltipHtml; + } + + registerTooltipText(ownerId, text, isHighPriority = false) { + super.registerTooltipText(ownerId, text, isHighPriority); + if (Constants.IS_BACKGROUND) + Tab.broadcastTooltipText(this.raw); + } + + unregisterTooltipText(ownerId) { + super.unregisterTooltipText(ownerId); + if (Constants.IS_BACKGROUND) + Tab.broadcastTooltipText(this.raw); + } + + get collapsedByParent() { + return this._shouldBeCollapsedByParent(); + } + get promisedCollapsedByParent() { + if (this.raw.groupId == -1) { + return this.collapsedByParent; + } + return browser.tabGroups.get(this.raw.groupId).then(group => { + return this._shouldBeCollapsedByParent(group) + }); + } + _shouldBeCollapsedByParent(group) { + if (this.raw.groupId == -1) { + return !!this.topmostSubtreeCollapsedAncestor; + } + if (this.raw.active) { + // simulate "visible active tab in collapsed tab group" behavior of Firefox itself + return false; + } + if (this.topmostSubtreeCollapsedAncestor) { + return true; + } + return (group || this.nativeTabGroup)?.collapsed; + } + + //=================================================================== + // neighbor tabs + //=================================================================== + + get nextTab() { + return this.raw && TabsStore.query({ + windowId: this.raw.windowId, + tabs: TabsStore.controllableTabsInWindow.get(this.raw.windowId), + fromId: this.id, + controllable: true, + index: (index => index > this.raw.index) + }); + } + + get previousTab() { + return this.raw && TabsStore.query({ + windowId: this.raw.windowId, + tabs: TabsStore.controllableTabsInWindow.get(this.raw.windowId), + fromId: this.id, + controllable: true, + index: (index => index < this.raw.index), + last: true + }); + } + + get unsafeNextTab() { + return this.raw && TabsStore.query({ + windowId: this.raw.windowId, + fromId: this.id, + index: (index => index > this.raw.index) + }); + } + + get unsafePreviousTab() { + return this.raw && TabsStore.query({ + windowId: this.raw.windowId, + fromId: this.id, + index: (index => index < this.raw.index), + last: true + }); + } + + get nearestCompletelyOpenedNormalFollowingTab() { // including hidden tabs! + return this.raw && TabsStore.query({ + windowId: this.raw.windowId, + tabs: TabsStore.unpinnedTabsInWindow.get(this.raw.windowId), + states: [Constants.kTAB_STATE_CREATING, false], + fromId: this.id, + living: true, + index: (index => index > this.raw.index) + }); + } + + get nearestCompletelyOpenedNormalPrecedingTab() { // including hidden tabs! + return this.raw && TabsStore.query({ + windowId: this.raw.windowId, + tabs: TabsStore.unpinnedTabsInWindow.get(this.raw.windowId), + states: [Constants.kTAB_STATE_CREATING, false], + fromId: this.id, + living: true, + index: (index => index < this.raw.index), + last: true + }); + } + + get nearestVisibleFollowingTab() { // visible, not-collapsed + return this.raw && TabsStore.query({ + windowId: this.raw.windowId, + tabs: TabsStore.visibleTabsInWindow.get(this.raw.windowId), + fromId: this.id, + visible: true, + index: (index => index > this.raw.index) + }); + } + + get unsafeNearestExpandedFollowingTab() { // not-collapsed, possibly hidden + return this.raw && TabsStore.query({ + windowId: this.raw.windowId, + tabs: TabsStore.expandedTabsInWindow.get(this.raw.windowId), + fromId: this.id, + index: (index => index > this.raw.index) + }); + } + + get nearestVisiblePrecedingTab() { // visible, not-collapsed + return this.raw && TabsStore.query({ + windowId: this.raw.windowId, + tabs: TabsStore.visibleTabsInWindow.get(this.raw.windowId), + fromId: this.id, + visible: true, + index: (index => index < this.raw.index), + last: true + }); + } + + get unsafeNearestExpandedPrecedingTab() { // not-collapsed, possibly hidden + return this.raw && TabsStore.query({ + windowId: this.raw.windowId, + tabs: TabsStore.expandedTabsInWindow.get(this.raw.windowId), + fromId: this.id, + index: (index => index < this.raw.index), + last: true + }); + } + + get nearestLoadedTab() { + const tabs = this.raw && TabsStore.visibleTabsInWindow.get(this.raw.windowId); + return this.raw && ( + // nearest following tab + TabsStore.query({ + windowId: this.raw.windowId, + tabs, + discarded: false, + fromId: this.id, + visible: true, + index: (index => index > this.raw.index) + }) || + // nearest preceding tab + TabsStore.query({ + windowId: this.raw.windowId, + tabs, + discarded: false, + fromId: this.id, + visible: true, + index: (index => index < this.raw.index), + last: true + }) + ); + } + + get nearestLoadedTabInTree() { + if (!this.raw) + return null; + let tab = this.raw; + const tabs = TabsStore.visibleTabsInWindow.get(tab.windowId); + let lastLastDescendant; + while (tab) { + const parent = tab.$TST.parent; + if (!parent) + return null; + const lastDescendant = parent.$TST.lastDescendant; + const loadedTab = ( + // nearest following tab + TabsStore.query({ + windowId: tab.windowId, + tabs, + descendantOf: parent.id, + discarded: false, + '!id': this.id, + fromId: (lastLastDescendant || this.raw).id, + toId: lastDescendant.id, + visible: true, + index: (index => index > this.raw.index) + }) || + // nearest preceding tab + TabsStore.query({ + windowId: tab.windowId, + tabs, + descendantOf: parent.id, + discarded: false, + '!id': this.id, + fromId: tab.id, + toId: parent.$TST.firstChild.id, + visible: true, + index: (index => index < tab.index), + last: true + }) + ); + if (loadedTab) + return loadedTab; + if (!parent.discarded) + return parent; + lastLastDescendant = lastDescendant; + tab = tab.$TST.parent; + } + return null; + } + + get nearestLoadedSiblingTab() { + const parent = this.parent; + if (!parent || !this.raw) + return null; + const tabs = TabsStore.visibleTabsInWindow.get(this.raw.windowId); + return ( + // nearest following tab + TabsStore.query({ + windowId: this.raw.windowId, + tabs, + childOf: parent.id, + discarded: false, + fromId: this.id, + toId: parent.$TST.lastChild.id, + visible: true, + index: (index => index > this.raw.index) + }) || + // nearest preceding tab + TabsStore.query({ + windowId: this.raw.windowId, + tabs, + childOf: parent.id, + discarded: false, + fromId: this.id, + toId: parent.$TST.firstChild.id, + visible: true, + index: (index => index < this.raw.index), + last: true + }) + ); + } + + get nearestSameTypeRenderedTab() { + let tab = this.raw; + const pinned = tab.pinned; + while (tab.$TST.unsafeNextTab) { + tab = tab.$TST.unsafeNextTab; + if (tab.pinned != pinned) + return null; + if (tab.$TST.element && + tab.$TST.element.parentNode) + return tab; + } + return null; + } + + //=================================================================== + // tree relations + //=================================================================== + + set parent(tab) { + const newParentId = tab && (typeof tab == 'number' ? tab : tab.id); + if (!this.raw || + newParentId == this.parentId) + return tab; + + const oldParent = this.parent; + this.parentId = newParentId; + this.invalidateCachedAncestors(); + const parent = this.parent; + if (parent) { + this.setAttribute(Constants.kPARENT, parent.id); + parent.$TST.invalidateCachedDescendants(); + + if (this.states.has(Constants.kTAB_STATE_SOUND_PLAYING)) + parent.$TST.soundPlayingChildrenIds.add(this.id); + if (this.states.has(Constants.kTAB_STATE_HAS_SOUND_PLAYING_MEMBER)) + parent.$TST.maybeSoundPlayingChildrenIds.add(this.id); + if (this.states.has(Constants.kTAB_STATE_MUTED)) + parent.$TST.mutedChildrenIds.add(this.id); + if (this.states.has(Constants.kTAB_STATE_HAS_MUTED_MEMBER)) + parent.$TST.maybeMutedChildrenIds.add(this.id); + if (this.states.has(Constants.kTAB_STATE_AUTOPLAY_BLOCKED)) + parent.$TST.autoplayBlockedChildrenIds.add(this.id); + if (this.states.has(Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER)) + parent.$TST.maybeAutoplayBlockedChildrenIds.add(this.id); + parent.$TST.inheritSoundStateFromChildren(); + + if (this.states.has(Constants.kTAB_STATE_SHARING_CAMERA)) + parent.$TST.sharingCameraChildrenIds.add(this.id); + if (this.states.has(Constants.kTAB_STATE_HAS_SHARING_CAMERA_MEMBER)) + parent.$TST.maybeSharingCameraChildrenIds.add(this.id); + if (this.states.has(Constants.kTAB_STATE_SHARING_MICROPHONE)) + parent.$TST.sharingMicrophoneChildrenIds.add(this.id); + if (this.states.has(Constants.kTAB_STATE_HAS_SHARING_MICROPHONE_MEMBER)) + parent.$TST.maybeSharingMicrophoneChildrenIds.add(this.id); + if (this.states.has(Constants.kTAB_STATE_SHARING_SCREEN)) + parent.$TST.sharingScreenChildrenIds.add(this.id); + if (this.states.has(Constants.kTAB_STATE_HAS_SHARING_SCREEN_MEMBER)) + parent.$TST.maybeSharingScreenChildrenIds.add(this.id); + parent.$TST.inheritSharingStateFromChildren(); + + TabsStore.removeRootTab(this.raw); + } + else { + this.removeAttribute(Constants.kPARENT); + TabsStore.addRootTab(this.raw); + } + if (oldParent && oldParent.id != this.parentId) { + oldParent.$TST.soundPlayingChildrenIds.delete(this.id); + oldParent.$TST.maybeSoundPlayingChildrenIds.delete(this.id); + oldParent.$TST.mutedChildrenIds.delete(this.id); + oldParent.$TST.maybeMutedChildrenIds.delete(this.id); + oldParent.$TST.autoplayBlockedChildrenIds.delete(this.id); + oldParent.$TST.maybeAutoplayBlockedChildrenIds.delete(this.id); + oldParent.$TST.inheritSoundStateFromChildren(); + + oldParent.$TST.sharingCameraChildrenIds.delete(this.id); + oldParent.$TST.maybeSharingCameraChildrenIds.delete(this.id); + oldParent.$TST.sharingMicrophoneChildrenIds.delete(this.id); + oldParent.$TST.maybeSharingScreenChildrenIds.delete(this.id); + oldParent.$TST.maybeSharingMicrophoneChildrenIds.delete(this.id); + oldParent.$TST.maybeSharingScreenChildrenIds.delete(this.id); + oldParent.$TST.inheritSharingStateFromChildren(); + + oldParent.$TST.children = oldParent.$TST.childIds.filter(id => id != this.id); + } + return tab; + } + get parent() { + return this.raw && this.parentId && TabsStore.ensureLivingItem(Tab.get(this.parentId)); + } + + get hasParent() { + return !!this.parentId; + } + + get ancestorIds() { + if (!this.cachedAncestorIds) + this.updateAncestors(); + return this.cachedAncestorIds; + } + + get ancestors() { + return mapAndFilter(this.ancestorIds, + id => TabsStore.ensureLivingItem(Tab.get(id)) || undefined); + } + + updateAncestors() { + const ancestors = []; + this.cachedAncestorIds = []; + if (!this.raw) + return ancestors; + let descendant = this.raw; + while (true) { + const parent = Tab.get(descendant.$TST.parentId); + if (!parent) + break; + ancestors.push(parent); + this.cachedAncestorIds.push(parent.id); + descendant = parent; + } + return ancestors; + } + + get level() { + return this.ancestorIds.length; + } + + invalidateCachedAncestors() { + this.cachedAncestorIds = null; + for (const child of this.children) { + child.$TST.invalidateCachedAncestors(); + } + this.invalidateCache(); + } + + get rootTab() { + const ancestors = this.ancestors; + return ancestors.length > 0 ? ancestors[ancestors.length-1] : this.raw ; + } + + get topmostSubtreeCollapsedAncestor() { + for (const ancestor of [...this.ancestors].reverse()) { + if (ancestor.$TST.subtreeCollapsed) + return ancestor; + } + return null; + } + + get nearestVisibleAncestorOrSelf() { + for (const ancestor of this.ancestors) { + if (!ancestor.$TST.collapsed) + return ancestor; + } + if (!this.collapsed) + return this.raw; + return null; + } + + get nearestFollowingRootTab() { + return TabsStore.query({ + windowId: this.raw.windowId, + tabs: TabsStore.rootTabsInWindow.get(this.raw.windowId), + fromId: this.id, + living: true, + index: (index => index > this.raw.index), + hasParent: false, + first: true + }); + } + + set children(tabs) { + if (!this.raw) + return tabs; + + const ancestorIds = this.ancestorIds; + const newChildIds = mapAndFilter(tabs, tab => { + const id = typeof tab == 'number' ? tab : tab?.id; + if (!ancestorIds.includes(id)) + return TabsStore.ensureLivingItem(Tab.get(id)) ? id : undefined; + console.log('FATAL ERROR: Cyclic tree structure has detected and prevented. ', { + ancestorsOfSelf: this.ancestors, + tabs, + tab, + stack: new Error().stack + }); + return undefined; + }); + if (newChildIds.join('|') == this.childIds.join('|')) + return tabs; + + const oldChildren = this.children; + this.childIds = newChildIds; + this.sortAndInvalidateChildren(); + if (this.childIds.length > 0) { + this.setAttribute(Constants.kCHILDREN, `|${this.childIds.join('|')}|`); + if (this.isSubtreeCollapsable) + TabsStore.addSubtreeCollapsableTab(this.raw); + } + else { + this.removeAttribute(Constants.kCHILDREN); + TabsStore.removeSubtreeCollapsableTab(this.raw); + } + for (const child of Array.from(new Set(this.children.concat(oldChildren)))) { + if (this.childIds.includes(child.id)) + child.$TST.parent = this.id; + else + child.$TST.parent = null; + } + return tabs; + } + get children() { + return mapAndFilter(this.childIds, + id => TabsStore.ensureLivingItem(Tab.get(id)) || undefined); + } + + sortAndInvalidateChildren() { + // Tab.get(tabId) calls into TabsStore.tabs.get(tabId), which is just a + // Map. This is acceptable to repeat in order to avoid two array copies, + // especially on larger tab sets. + this.childIds.sort((a, b) => TreeItem.compare(Tab.get(a), Tab.get(b))); + this.invalidateCachedDescendants(); + } + + get hasChild() { + return this.childIds.length > 0; + } + + get descendants() { + if (!this.cachedDescendantIds) + return this.updateDescendants(); + return mapAndFilter(this.cachedDescendantIds, + id => TabsStore.ensureLivingItem(Tab.get(id)) || undefined); + } + + updateDescendants() { + let descendants = []; + this.cachedDescendantIds = []; + for (const child of this.children) { + descendants.push(child); + descendants = descendants.concat(child.$TST.descendants); + this.cachedDescendantIds.push(child.id); + this.cachedDescendantIds = this.cachedDescendantIds.concat(child.$TST.cachedDescendantIds); + } + return descendants; + } + + invalidateCachedDescendants() { + this.cachedDescendantIds = null; + const parent = this.parent; + if (parent) + parent.$TST.invalidateCachedDescendants(); + this.invalidateCache(); + } + + get nextSiblingTab() { + if (!this.raw) + return null; + const parent = this.parent; + if (parent) { + const siblingIds = parent.$TST.childIds; + const index = siblingIds.indexOf(this.id); + const siblingId = index < siblingIds.length - 1 ? siblingIds[index + 1] : null ; + if (!siblingId) + return null; + return Tab.get(siblingId); + } + else { + const nextSibling = TabsStore.query({ + windowId: this.raw.windowId, + tabs: TabsStore.rootTabsInWindow.get(this.raw.windowId), + fromId: this.id, + living: true, + index: (index => index > this.raw.index), + hasParent: false, + first: true + }); + // We should treat only pinned tab as the next sibling tab of a pinned + // tab. For example, if the last pinned tab is closed, Firefox moves + // focus to the first normal tab. But the previous pinned tab looks + // natural on TST because pinned tabs are visually grouped. + if (nextSibling && + nextSibling.pinned != this.raw.pinned) + return null; + return nextSibling; + } + } + + get previousSiblingTab() { + if (!this.raw) + return null; + const parent = this.parent; + if (parent) { + const siblingIds = parent.$TST.childIds; + const index = siblingIds.indexOf(this.id); + const siblingId = index > 0 ? siblingIds[index - 1] : null ; + if (!siblingId) + return null; + return Tab.get(siblingId); + } + else { + return TabsStore.query({ + windowId: this.raw.windowId, + tabs: TabsStore.rootTabsInWindow.get(this.raw.windowId), + fromId: this.id, + living: true, + index: (index => index < this.raw.index), + hasParent: false, + last: true + }); + } + } + + get needToBeGroupedSiblings() { + if (!this.raw) + return []; + const openerTabUniqueId = this.getAttribute(Constants.kPERSISTENT_ORIGINAL_OPENER_TAB_ID); + if (!openerTabUniqueId) + return []; + return TabsStore.queryAll({ + windowId: this.raw.windowId, + tabs: TabsStore.toBeGroupedTabsInWindow.get(this.raw.windowId), + normal: true, + '!id': this.id, + attributes: [ + Constants.kPERSISTENT_ORIGINAL_OPENER_TAB_ID, openerTabUniqueId, + Constants.kPERSISTENT_ALREADY_GROUPED_FOR_PINNED_OPENER, '' + ], + ordered: true + }); + } + + get precedingCanBecomeStickyTabs() { + return TabsStore.queryAll({ + windowId: this.raw.windowId, + tabs: TabsStore.canBecomeStickyTabsInWindow.get(this.raw.windowId), + normal: true, + '!id': this.id, + ordered: true, + fromId: this.id, + reversed: true, + }); + } + + get followingCanBecomeStickyTabs() { + return TabsStore.queryAll({ + windowId: this.raw.windowId, + tabs: TabsStore.canBecomeStickyTabsInWindow.get(this.raw.windowId), + normal: true, + '!id': this.id, + ordered: true, + fromId: this.id, + }); + } + + //=================================================================== + // other relations + //=================================================================== + + get openerTab() { + if (this.raw?.openerTabId == this.id) + return null; + + if (!this.raw?.openerTabId) + return Tab.getOpenerFromGroupTab(this.raw); + + return TabsStore.query({ + windowId: this.raw.windowId, + tabs: TabsStore.livingTabsInWindow.get(this.raw.windowId), + id: this.raw.openerTabId, + living: true + }); + } + + get hasPinnedOpener() { + return this.openerTab?.pinned; + } + + get hasFirefoxViewOpener() { + return isFirefoxViewTab(this.openerTab); + } + + get bundledTab() { + if (!this.raw) + return null; + const substance = Tab.getSubstanceFromAliasGroupTab(this.raw); + if (substance) + return substance; + if (this.raw.pinned) + return Tab.getGroupTabForOpener(this.raw); + if (this.isGroupTab) + return Tab.getOpenerFromGroupTab(this.raw); + return null; + } + + get bundledTabId() { + const tab = this.bundledTab; + return tab ? tab.id : -1; + } + + findSuccessor(options = {}) { + if (!this.raw) + return null; + if (typeof options != 'object') + options = {}; + const ignoredTabs = (options.ignoredTabs || []).slice(0); + let foundTab = this.raw; + do { + ignoredTabs.push(foundTab); + foundTab = foundTab.$TST.nextTab; + } while (foundTab && ignoredTabs.includes(foundTab)); + if (!foundTab) { + foundTab = this.raw; + do { + ignoredTabs.push(foundTab); + foundTab = foundTab.$TST.nearestVisiblePrecedingTab; + } while (foundTab && ignoredTabs.includes(foundTab)); + } + return foundTab; + } + + get lastRelatedTab() { + return Tab.get(this.lastRelatedTabId) || null; + } + set lastRelatedTab(relatedTab) { + if (!this.raw) + return relatedTab; + const previousLastRelatedTabId = this.lastRelatedTabId; + const win = TabsStore.windows.get(this.raw.windowId); + if (relatedTab) { + win.lastRelatedTabs.set(this.id, relatedTab.id); + this.newRelatedTabsCount++; + successorTabLog(`set lastRelatedTab for ${this.id}: ${previousLastRelatedTabId} => ${relatedTab.id} (${this.newRelatedTabsCount})`); + } + else { + win.lastRelatedTabs.delete(this.id); + this.newRelatedTabsCount = 0; + successorTabLog(`clear lastRelatedTab for ${this.id} (${previousLastRelatedTabId})`); + } + win.previousLastRelatedTabs.set(this.id, previousLastRelatedTabId); + return relatedTab; + } + + get lastRelatedTabId() { + if (!this.raw) + return 0; + const win = TabsStore.windows.get(this.raw.windowId); + return win.lastRelatedTabs.get(this.id) || 0; + } + + get previousLastRelatedTab() { + if (!this.raw) + return null; + const win = TabsStore.windows.get(this.raw.windowId); + return Tab.get(win.previousLastRelatedTabs.get(this.id)) || null; + } + + detach() { + this.parent = null; + this.children = []; + } + + + //=================================================================== + // State + //=================================================================== + + async addState(state, { permanently, toTab, broadcast } = {}) { + state = state && String(state) || undefined; + if (!this.raw || !state) + return; + + const modified = this.states && !this.states.has(state); + + super.addState(state); + + switch (state) { + case Constants.kTAB_STATE_HIGHLIGHTED: + TabsStore.addHighlightedTab(this.raw); + if (this.element) + this.element.setAttribute('aria-selected', 'true'); + if (toTab) + this.raw.highlighted = true; + break; + + case Constants.kTAB_STATE_SELECTED: + TabsStore.addSelectedTab(this.raw); + break; + + case Constants.kTAB_STATE_COLLAPSED: + case Constants.kTAB_STATE_SUBTREE_COLLAPSED: + if (this.isSubtreeCollapsable) + TabsStore.addSubtreeCollapsableTab(this.raw); + else + TabsStore.removeSubtreeCollapsableTab(this.raw); + break; + + case Constants.kTAB_STATE_HIDDEN: + TabsStore.removeVisibleTab(this.raw); + TabsStore.removeControllableTab(this.raw); + if (toTab) + this.raw.hidden = true; + break; + + case Constants.kTAB_STATE_PINNED: + TabsStore.addPinnedTab(this.raw); + TabsStore.removeUnpinnedTab(this.raw); + if (toTab) + this.raw.pinned = true; + break; + + case Constants.kTAB_STATE_BUNDLED_ACTIVE: + TabsStore.addBundledActiveTab(this.raw); + break; + + case Constants.kTAB_STATE_SOUND_PLAYING: { + const parent = this.parent; + if (parent) + parent.$TST.soundPlayingChildrenIds.add(this.id); + } break; + case Constants.kTAB_STATE_HAS_SOUND_PLAYING_MEMBER: { + const parent = this.parent; + if (parent) + parent.$TST.maybeSoundPlayingChildrenIds.add(this.id); + } break; + + case Constants.kTAB_STATE_AUDIBLE: + if (toTab) + this.raw.audible = true; + break; + + case Constants.kTAB_STATE_MUTED: { + const parent = this.parent; + if (parent) + parent.$TST.mutedChildrenIds.add(this.id); + if (toTab) + this.raw.mutedInfo.muted = true; + } break; + case Constants.kTAB_STATE_HAS_MUTED_MEMBER: { + const parent = this.parent; + if (parent) + parent.$TST.maybeMutedChildrenIds.add(this.id); + } break; + + case Constants.kTAB_STATE_AUTOPLAY_BLOCKED: { + const parent = this.parent; + if (parent) { + parent.$TST.autoplayBlockedChildrenIds.add(this.id); + parent.$TST.inheritSoundStateFromChildren(); + } + } break; + case Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER: { + const parent = this.parent; + if (parent) { + parent.$TST.maybeAutoplayBlockedChildrenIds.add(this.id); + parent.$TST.inheritSoundStateFromChildren(); + } + } break; + + case Constants.kTAB_STATE_SHARING_CAMERA: { + const parent = this.parent; + if (parent) + parent.$TST.sharingCameraChildrenIds.add(this.id); + if (toTab && this.raw.sharingState) + this.raw.sharingState.camera = true; + } break; + case Constants.kTAB_STATE_HAS_SHARING_CAMERA_MEMBER: { + const parent = this.parent; + if (parent) + parent.$TST.maybeSharingCameraChildrenIds.add(this.id); + } break; + + case Constants.kTAB_STATE_SHARING_MICROPHONE: { + const parent = this.parent; + if (parent) + parent.$TST.sharingMicrophoneChildrenIds.add(this.id); + if (toTab && this.raw.sharingState) + this.raw.sharingState.microphone = true; + } break; + case Constants.kTAB_STATE_HAS_SHARING_MICROPHONE_MEMBER: { + const parent = this.parent; + if (parent) + parent.$TST.maybeSharingMicrophoneChildrenIds.add(this.id); + } break; + + case Constants.kTAB_STATE_SHARING_SCREEN: { + const parent = this.parent; + if (parent) + parent.$TST.sharingScreenChildrenIds.add(this.id); + if (toTab && this.raw.sharingState) + this.raw.sharingState.screen = 'Something'; + } break; + case Constants.kTAB_STATE_HAS_SHARING_SCREEN_MEMBER: { + const parent = this.parent; + if (parent) + parent.$TST.maybeSharingScreenChildrenIds.add(this.id); + } break; + + case Constants.kTAB_STATE_GROUP_TAB: + TabsStore.addGroupTab(this.raw); + break; + + case Constants.kTAB_STATE_PRIVATE_BROWSING: + if (toTab) + this.raw.incognito = true; + break; + + case Constants.kTAB_STATE_ATTENTION: + if (toTab) + this.raw.attention = true; + break; + + case Constants.kTAB_STATE_DISCARDED: + if (toTab) + this.raw.discarded = true; + break; + + case 'loading': + TabsStore.addLoadingTab(this.raw); + if (toTab) + this.raw.status = state; + break; + + case 'complete': + TabsStore.removeLoadingTab(this.raw); + if (toTab) + this.raw.status = state; + break; + } + + if (TreeItem.allAutoStickyStates.has(state)) { + if (this.canBecomeSticky) + TabsStore.addCanBecomeStickyTab(this.raw); + else + TabsStore.removeCanBecomeStickyTab(this.raw); + } + + if (this.raw && + modified && + state != Constants.kTAB_STATE_ACTIVE && + Constants.IS_BACKGROUND && + broadcast !== false) + Tab.broadcastState(this.raw, { + add: [state], + }); + if (permanently) { + const states = await this.getPermanentStates(); + if (!states.includes(state)) { + states.push(state); + await browser.sessions.setTabValue(this.id, Constants.kPERSISTENT_STATES, states).catch(ApiTabs.createErrorSuppressor()); + } + } + if (modified) { + this.invalidateCache(); + if (this.raw) + Tab.onStateChanged.dispatch(this.raw, state, true); + } + } + + async removeState(state, { permanently, toTab, broadcast } = {}) { + state = state && String(state) || undefined; + if (!this.raw || !state) + return; + + const modified = this.states?.has(state); + + super.removeState(state); + + switch (state) { + case Constants.kTAB_STATE_HIGHLIGHTED: + TabsStore.removeHighlightedTab(this.raw); + if (this.element) + this.element.setAttribute('aria-selected', 'false'); + if (toTab) + this.raw.highlighted = false; + break; + + case Constants.kTAB_STATE_SELECTED: + TabsStore.removeSelectedTab(this.raw); + break; + + case Constants.kTAB_STATE_COLLAPSED: + case Constants.kTAB_STATE_SUBTREE_COLLAPSED: + if (this.isSubtreeCollapsable) + TabsStore.addSubtreeCollapsableTab(this.raw); + else + TabsStore.removeSubtreeCollapsableTab(this.raw); + break; + + case Constants.kTAB_STATE_HIDDEN: + if (!this.collapsed) + TabsStore.addVisibleTab(this.raw); + TabsStore.addControllableTab(this.raw); + if (toTab) + this.raw.hidden = false; + break; + + case Constants.kTAB_STATE_PINNED: + TabsStore.removePinnedTab(this.raw); + TabsStore.addUnpinnedTab(this.raw); + if (toTab) + this.raw.pinned = false; + break; + + case Constants.kTAB_STATE_BUNDLED_ACTIVE: + TabsStore.removeBundledActiveTab(this.raw); + break; + + case Constants.kTAB_STATE_SOUND_PLAYING: { + const parent = this.parent; + if (parent) + parent.$TST.soundPlayingChildrenIds.delete(this.id); + } break; + case Constants.kTAB_STATE_HAS_SOUND_PLAYING_MEMBER: { + const parent = this.parent; + if (parent) + parent.$TST.maybeSoundPlayingChildrenIds.delete(this.id); + } break; + + case Constants.kTAB_STATE_AUDIBLE: + if (toTab) + this.raw.audible = false; + break; + + case Constants.kTAB_STATE_MUTED: { + const parent = this.parent; + if (parent) + parent.$TST.mutedChildrenIds.delete(this.id); + if (toTab) + this.raw.mutedInfo.muted = false; + } break; + case Constants.kTAB_STATE_HAS_MUTED_MEMBER: { + const parent = this.parent; + if (parent) + parent.$TST.maybeMutedChildrenIds.delete(this.id); + } break; + + case Constants.kTAB_STATE_AUTOPLAY_BLOCKED: { + const parent = this.parent; + if (parent) { + parent.$TST.autoplayBlockedChildrenIds.delete(this.id); + parent.$TST.inheritSoundStateFromChildren(); + } + } break; + case Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER: { + const parent = this.parent; + if (parent) { + parent.$TST.maybeAutoplayBlockedChildrenIds.delete(this.id); + parent.$TST.inheritSoundStateFromChildren(); + } + } break; + + case Constants.kTAB_STATE_SHARING_CAMERA: { + const parent = this.parent; + if (parent) + parent.$TST.sharingCameraChildrenIds.delete(this.id); + if (toTab && this.raw.sharingState) + this.raw.sharingState.camera = false; + } break; + case Constants.kTAB_STATE_HAS_SHARING_CAMERA_MEMBER: { + const parent = this.parent; + if (parent) + parent.$TST.maybeSharingCameraChildrenIds.delete(this.id); + } break; + + case Constants.kTAB_STATE_SHARING_MICROPHONE: { + const parent = this.parent; + if (parent) + parent.$TST.sharingMicrophoneChildrenIds.delete(this.id); + if (toTab && this.raw.sharingState) + this.raw.sharingState.microphone = false; + } break; + case Constants.kTAB_STATE_HAS_SHARING_MICROPHONE_MEMBER: { + const parent = this.parent; + if (parent) + parent.$TST.maybeSharingMicrophoneChildrenIds.delete(this.id); + } break; + + case Constants.kTAB_STATE_SHARING_SCREEN: { + const parent = this.parent; + if (parent) + parent.$TST.sharingScreenChildrenIds.delete(this.id); + if (toTab && this.raw.sharingState) + this.raw.sharingState.screen = undefined; + } break; + case Constants.kTAB_STATE_HAS_SHARING_SCREEN_MEMBER: { + const parent = this.parent; + if (parent) + parent.$TST.maybeSharingScreenChildrenIds.delete(this.id); + } break; + + case Constants.kTAB_STATE_GROUP_TAB: + TabsStore.removeGroupTab(this.raw); + break; + + case Constants.kTAB_STATE_PRIVATE_BROWSING: + if (toTab) + this.raw.incognito = false; + break; + + case Constants.kTAB_STATE_ATTENTION: + if (toTab) + this.raw.attention = false; + break; + + case Constants.kTAB_STATE_DISCARDED: + if (toTab) + this.raw.discarded = false; + break; + } + + if (TreeItem.allAutoStickyStates.has(state)) { + if (this.canBecomeSticky) + TabsStore.addCanBecomeStickyTab(this.raw); + else + TabsStore.removeCanBecomeStickyTab(this.raw); + } + + if (modified && + state != Constants.kTAB_STATE_ACTIVE && + Constants.IS_BACKGROUND && + broadcast !== false) + Tab.broadcastState(this.raw, { + remove: [state], + }); + if (permanently) { + const states = await this.getPermanentStates(); + const index = states.indexOf(state); + if (index > -1) { + states.splice(index, 1); + await browser.sessions.setTabValue(this.id, Constants.kPERSISTENT_STATES, states).catch(ApiTabs.createErrorSuppressor()); + } + } + if (modified) { + this.invalidateCache(); + Tab.onStateChanged.dispatch(this.raw, state, false); + } + } + + async getPermanentStates() { + const states = this.raw && await browser.sessions.getTabValue(this.id, Constants.kPERSISTENT_STATES).catch(ApiTabs.handleMissingTabError); + // We need to cleanup invalid values stored accidentally. + // See also: https://github.com/piroor/treestyletab/issues/2882 + return states && mapAndFilterUniq(states, state => state && String(state) || undefined) || []; + } + + inheritSoundStateFromChildren() { + if (!this.raw) + return; + + // this is called too many times on a session restoration, so this should be throttled for better performance + if (this.delayedInheritSoundStateFromChildren) + clearTimeout(this.delayedInheritSoundStateFromChildren); + + this.delayedInheritSoundStateFromChildren = setTimeout(() => { + this.delayedInheritSoundStateFromChildren = null; + if (!TabsStore.ensureLivingItem(this.raw)) + return; + + const parent = this.parent; + let modifiedCount = 0; + + const soundPlayingCount = this.soundPlayingChildrenIds.size + this.maybeSoundPlayingChildrenIds.size; + if (soundPlayingCount != this.lastSoundStateCounts.soundPlaying) { + this.lastSoundStateCounts.soundPlaying = soundPlayingCount; + this.toggleState(Constants.kTAB_STATE_HAS_SOUND_PLAYING_MEMBER, soundPlayingCount > 0); + if (parent) { + if (soundPlayingCount > 0) + parent.$TST.maybeSoundPlayingChildrenIds.add(this.id); + else + parent.$TST.maybeSoundPlayingChildrenIds.delete(this.id); + } + modifiedCount++; + } + + const mutedCount = this.mutedChildrenIds.size + this.maybeMutedChildrenIds.size; + if (mutedCount != this.lastSoundStateCounts.muted) { + this.lastSoundStateCounts.muted = mutedCount; + this.toggleState(Constants.kTAB_STATE_HAS_MUTED_MEMBER, mutedCount > 0); + if (parent) { + if (mutedCount > 0) + parent.$TST.maybeMutedChildrenIds.add(this.id); + else + parent.$TST.maybeMutedChildrenIds.delete(this.id); + } + modifiedCount++; + } + + const autoplayBlockedCount = this.autoplayBlockedChildrenIds.size + this.maybeAutoplayBlockedChildrenIds.size; + if (autoplayBlockedCount != this.lastSoundStateCounts.autoplayBlocked) { + this.lastSoundStateCounts.autoplayBlocked = autoplayBlockedCount; + this.toggleState(Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER, autoplayBlockedCount > 0); + if (parent) { + if (autoplayBlockedCount > 0) + parent.$TST.maybeAutoplayBlockedChildrenIds.add(this.id); + else + parent.$TST.maybeAutoplayBlockedChildrenIds.delete(this.id); + } + modifiedCount++; + } + + if (modifiedCount == 0) + return; + + if (parent) + parent.$TST.inheritSoundStateFromChildren(); + + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_SOUND_STATE_UPDATED, + windowId: this.raw.windowId, + tabId: this.id, + hasSoundPlayingMember: this.states.has(Constants.kTAB_STATE_HAS_SOUND_PLAYING_MEMBER), + hasMutedMember: this.states.has(Constants.kTAB_STATE_HAS_MUTED_MEMBER), + hasAutoplayBlockedMember: this.states.has(Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER), + }); + }, 100); + } + + inheritSharingStateFromChildren() { + if (!this.raw) + return; + + // this is called too many times on a session restoration, so this should be throttled for better performance + if (this.delayedInheritSharingStateFromChildren) + clearTimeout(this.delayedInheritSharingStateFromChildren); + + this.delayedInheritSharingStateFromChildren = setTimeout(() => { + this.delayedInheritSharingStateFromChildren = null; + if (!TabsStore.ensureLivingItem(this.raw)) + return; + + const parent = this.parent; + let modifiedCount = 0; + + const sharingCameraCount = this.sharingCameraChildrenIds.size + this.maybeSharingCameraChildrenIds.size; + if (sharingCameraCount != this.lastSharingStateCounts.sharingCamera) { + this.lastSharingStateCounts.sharingCamera = sharingCameraCount; + this.toggleState(Constants.kTAB_STATE_HAS_SHARING_CAMERA_MEMBER, sharingCameraCount > 0); + if (parent) { + if (sharingCameraCount > 0) + parent.$TST.maybeSharingCameraChildrenIds.add(this.id); + else + parent.$TST.maybeSharingCameraChildrenIds.delete(this.id); + } + modifiedCount++; + } + + const sharingMicrophoneCount = this.sharingMicrophoneChildrenIds.size + this.maybeSharingMicrophoneChildrenIds.size; + if (sharingMicrophoneCount != this.lastSharingStateCounts.sharingMicrophone) { + this.lastSharingStateCounts.sharingMicrophone = sharingMicrophoneCount; + this.toggleState(Constants.kTAB_STATE_HAS_SHARING_MICROPHONE_MEMBER, sharingMicrophoneCount > 0); + if (parent) { + if (sharingMicrophoneCount > 0) + parent.$TST.maybeSharingMicrophoneChildrenIds.add(this.id); + else + parent.$TST.maybeSharingMicrophoneChildrenIds.delete(this.id); + } + modifiedCount++; + } + + const sharingScreenCount = this.sharingScreenChildrenIds.size + this.maybeSharingScreenChildrenIds.size; + if (sharingScreenCount != this.lastSharingStateCounts.sharingScreen) { + this.lastSharingStateCounts.sharingScreen = sharingScreenCount; + this.toggleState(Constants.kTAB_STATE_HAS_SHARING_SCREEN_MEMBER, sharingScreenCount > 0); + if (parent) { + if (sharingScreenCount > 0) + parent.$TST.maybeSharingScreenChildrenIds.add(this.id); + else + parent.$TST.maybeSharingScreenChildrenIds.delete(this.id); + } + modifiedCount++; + } + + if (modifiedCount == 0) + return; + + if (parent) + parent.$TST.inheritSharingStateFromChildren(); + + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_SHARING_STATE_UPDATED, + windowId: this.raw.windowId, + tabId: this.id, + hasSharingCameraMember: this.states.has(Constants.kTAB_STATE_HAS_SHARING_CAMERA_MEMBER), + hasSharingMicrophoneMember: this.states.has(Constants.kTAB_STATE_HAS_SHARING_MICROPHONE_MEMBER), + hasSharingScreenMember: this.states.has(Constants.kTAB_STATE_HAS_SHARING_SCREEN_MEMBER), + }); + }, 100); + } + + + onNativeGroupModified(oldGroupId) { + if (this.raw.groupId == -1) { + TabsStore.removeNativelyGroupedTab(this.raw); + } + else { + TabsStore.addNativelyGroupedTab(this.raw); + } + + this.setAttribute(Constants.kGROUP_ID, this.raw.groupId); + + const group = this.nativeTabGroup; + if (group) { + group.incognito = this.tab.incognito; + group.$TST.reindex(this.raw); + } + + if (oldGroupId && oldGroupId != -1) { + TabGroup.get(oldGroupId)?.$TST.reindex(); + } + + Tab.onNativeGroupModified.dispatch(this.raw); + } + + + setAttribute(attribute, value) { + super.setAttribute(attribute, value); + this.invalidateCache(); + } + + removeAttribute(attribute) { + super.removeAttribute(attribute); + this.invalidateCache(); + } + + + resolveOpened() { + if (!mOpenedResolvers.has(this.id)) + return; + for (const resolver of mOpenedResolvers.get(this.id)) { + resolver.resolve(); + } + mOpenedResolvers.delete(this.id); + } + rejectOpened() { + if (!mOpenedResolvers.has(this.id)) + return; + for (const resolver of mOpenedResolvers.get(this.id)) { + resolver.reject(); + } + mOpenedResolvers.delete(this.id); + } + + apply(exported) { // not optimized and unsafe yet! + if (!this.raw) + return; + + TabsStore.removeTabFromIndexes(this.raw); + + for (const key of Object.keys(exported)) { + if (key == '$TST') + continue; + if (key in this.raw) + this.raw[key] = exported[key]; + } + + this.uniqueId = exported.$TST.uniqueId; + this.promisedUniqueId = Promise.resolve(this.uniqueId); + + this.states = new Set(exported.$TST.states); + this.attributes = exported.$TST.attributes; + + this.parent = exported.$TST.parentId; + this.children = exported.$TST.childIds || []; + + TabsStore.updateIndexesForTab(this.raw); + } + + async exportFullTreeItemProperties(fullExportedTreeItem, { isContextTab, permissions, cache } = {}) { + const favIconUrl = await ( + (!permissions || + (!permissions.has(kPERMISSION_TABS) && + (!permissions.has(kPERMISSION_ACTIVE_TAB) || + !this.raw?.active))) ? + null : + (this.raw?.id in cache.effectiveFavIconUrls) ? + cache.effectiveFavIconUrls[this.raw?.id] : + this.raw?.favIconUrl?.startsWith('data:') ? + this.raw?.favIconUrl : + TabFavIconHelper.getLastEffectiveFavIconURL(this.raw).catch(ApiTabs.handleMissingTabError) + ); + + if (!(this.raw.id in cache.effectiveFavIconUrls)) + cache.effectiveFavIconUrls[this.raw.id] = favIconUrl; + + const allowedProperties = new Set([ + // basic tabs.Tab properties + 'active', + 'attention', + 'audible', + 'autoDiscardable', + 'discarded', + 'height', + 'hidden', + 'highlighted', + //'id', + 'incognito', + 'index', + 'isArticle', + 'isInReaderMode', + 'lastAccessed', + 'mutedInfo', + 'openerTabId', + 'pinned', + 'selected', + 'sessionId', + 'sharingState', + 'status', + 'successorId', + 'width', + //'windowId', + ]); + + if (permissions.has(kPERMISSION_TABS) || + (permissions.has(kPERMISSION_ACTIVE_TAB) && + (this.raw.active || + isContextTab))) { + // specially allowed with "tabs" or "activeTab" permission + allowedProperties.add('favIconUrl'); + allowedProperties.add('title'); + allowedProperties.add('url'); + fullExportedTreeItem.effectiveFavIconUrl = favIconUrl; + } + if (permissions.has(kPERMISSION_COOKIES)) { + allowedProperties.add('cookieStoreId'); + fullExportedTreeItem.cookieStoreName = this.raw.$TST.cookieStoreName; + } + + for (const property of allowedProperties) { + if (property in this.raw) + fullExportedTreeItem[property] = this.raw[property]; + } + } + + + applyStatesToElement() { + if (!this.element) + return; + + super.applyStatesToElement(); + + if (this.states.has(Constants.kTAB_STATE_HIGHLIGHTED)) { + this.element.setAttribute('aria-selected', 'true'); + } + } + + set favIconUrl(url) { + if (this.element && 'favIconUrl' in this.element) + this.element.favIconUrl = url; + this.invalidateCache(); + } + + + //=================================================================== + // class methods + //=================================================================== + + static track(tab) { + const trackedTab = Tab.get(tab.id); + if (!trackedTab || + !(tab.$TST instanceof Tab)) { + new Tab(tab); + } + else { + if (trackedTab) + tab = trackedTab; + const win = TabsStore.windows.get(tab.windowId); + win.trackTab(tab); + } + return trackedTab || tab; + } + + static untrack(tabId) { + const tab = Tab.get(tabId); + if (!tab) // already untracked + return; + const win = TabsStore.windows.get(tab.windowId); + if (win) + win.untrackTab(tabId); + } + + static isTracked(tabId) { + return TabsStore.tabs.has(tabId); + } + + static get(tabId) { + if (!tabId) { + return null; + } + if (tabId && typeof tabId.color !== 'undefined') { // for backward compatibility + return TabGroup.get(tabId.id); + } + return TabsStore.tabs.get(typeof tabId == 'number' ? tabId : tabId?.id); + } + + static getByUniqueId(id) { + if (!id) + return null; + return TabsStore.ensureLivingItem(TabsStore.tabsByUniqueId.get(id)); + } + + static needToWaitTracked(windowId) { + if (windowId) { + const tabs = mIncompletelyTrackedTabs.get(windowId); + return tabs && tabs.size > 0; + } + for (const tabs of mIncompletelyTrackedTabs.values()) { + if (tabs && tabs.size > 0) + return true; + } + return false; + } + + static async waitUntilTrackedAll(windowId, options = {}) { + const tabSets = windowId ? + [mIncompletelyTrackedTabs.get(windowId)] : + [...mIncompletelyTrackedTabs.values()]; + return Promise.all(tabSets.map(tabs => { + if (!tabs) + return; + let tabIds = Array.from(tabs, tab => tab.id); + if (options.exceptionTabId) + tabIds = tabIds.filter(id => id != options.exceptionTabId); + return Tab.waitUntilTracked(tabIds, options); + })); + } + + static async waitUntilTracked(tabId, options = {}) { + if (!tabId) + return null; + + if (Array.isArray(tabId)) + return Promise.all(tabId.map(id => Tab.waitUntilTracked(id, options))); + + const windowId = TabsStore.getCurrentWindowId(); + if (windowId) { + const tabs = TabsStore.removedTabsInWindow.get(windowId); + if (tabs?.has(tabId)) + return null; // already removed tab + } + + const key = `${tabId}:${!!options.element}`; + if (mPromisedTrackedTabs.has(key)) + return mPromisedTrackedTabs.get(key); + + const promisedTracked = waitUntilTracked(tabId, options); + mPromisedTrackedTabs.set(key, promisedTracked); + return promisedTracked.then(tab => { + // Don't claer the last promise, because it is required to process following "waitUntilTracked" callbacks sequentically. + //if (mPromisedTrackedTabs.get(key) == promisedTracked) + // mPromisedTrackedTabs.delete(key); + return tab; + }).catch(_error => { + //if (mPromisedTrackedTabs.get(key) == promisedTracked) + // mPromisedTrackedTabs.delete(key); + return null; + }); + } + + static needToWaitMoved(windowId) { + if (windowId) { + const tabs = mMovingTabs.get(windowId); + return tabs && tabs.size > 0; + } + for (const tabs of mMovingTabs.values()) { + if (tabs && tabs.size > 0) + return true; + } + return false; + } + + static async waitUntilMovedAll(windowId) { + const tabSets = []; + if (windowId) { + tabSets.push(mMovingTabs.get(windowId)); + } + else { + for (const tabs of mMovingTabs.values()) { + tabSets.push(tabs); + } + } + return Promise.all(tabSets.map(tabs => tabs && Promise.all(tabs))); + } + + static init(tab, options = {}) { + log('initalize tab ', tab); + if (!tab) { + const error = new Error('Fatal error: invalid tab is given to Tab.init()'); + console.log(error, error.stack); + throw error; + } + const trackedTab = Tab.get(tab.id); + if (trackedTab) + tab = trackedTab; + tab.$TST = trackedTab?.$TST || new Tab(tab); + tab.$TST.updateUniqueId().then(tab.$TST.onUniqueIdGenerated); + + if (tab.active) + tab.$TST.addState(Constants.kTAB_STATE_ACTIVE); + + // When a new "child" tab was opened and the "parent" tab was closed + // immediately by someone outside of TST, both new "child" and the + // "parent" were closed by TST because all new tabs had + // "subtree-collapsed" state initially and such an action was detected + // as "closing of a collapsed tree". + // The initial state was introduced in old versions, but I forgot why + // it was required. "When new child tab is attached, collapse other + // tree" behavior works as expected even if the initial state is not + // there. Thus I remove the initial state for now, to avoid the + // annoying problem. + // See also: https://github.com/piroor/treestyletab/issues/2162 + // tab.$TST.addState(Constants.kTAB_STATE_SUBTREE_COLLAPSED); + + Tab.onInitialized.dispatch(tab, options); + + if (options.existing) { + tab.$TST.addState(Constants.kTAB_STATE_ANIMATION_READY); + tab.$TST.opened = Promise.resolve(true).then(() => { + tab.$TST.resolveOpened(); + }); + tab.$TST.temporaryMetadata.delete('opening'); + tab.$TST.temporaryMetadata.set('openedCompletely', true); + } + else { + tab.$TST.temporaryMetadata.set('opening', true); + tab.$TST.temporaryMetadata.delete('openedCompletely'); + tab.$TST.opened = new Promise((resolve, reject) => { + tab.$TST.opening = false; + const resolvers = mOpenedResolvers.get(tab.id) || new Set(); + resolvers.add({ resolve, reject }); + mOpenedResolvers.set(tab.id, resolvers); + }).then(() => { + tab.$TST.temporaryMetadata.set('openedCompletely', true); + }); + } + + return tab; + } + + static import(tab) { + const existingTab = Tab.get(tab.id); + if (!existingTab) { + return Tab.init(tab); + } + existingTab.$TST.apply(tab); + return existingTab; + } + + //=================================================================== + // get single tab + //=================================================================== + + // Note that this function can return null if it is the first tab of + // a new window opened by the "move tab to new window" command. + static getActiveTab(windowId) { + return TabsStore.ensureLivingItem(TabsStore.activeTabInWindow.get(windowId)); + } + + static getFirstTab(windowId) { + return TabsStore.query({ + windowId, + tabs: TabsStore.livingTabsInWindow.get(windowId), + living: true, + ordered: true + }); + } + + static getLastTab(windowId) { + return TabsStore.query({ + windowId, + tabs: TabsStore.livingTabsInWindow.get(windowId), + living: true, + last: true + }); + } + + static getFirstVisibleTab(windowId) { // visible, not-collapsed, not-hidden + return TabsStore.query({ + windowId, + tabs: TabsStore.visibleTabsInWindow.get(windowId), + visible: true, + ordered: true + }); + } + + static getLastVisibleTab(windowId) { // visible, not-collapsed, not-hidden + return TabsStore.query({ + windowId, + tabs: TabsStore.visibleTabsInWindow.get(windowId), + visible: true, + last: true, + }); + } + + static getLastOpenedTab(windowId) { + const tabs = Tab.getTabs(windowId); + return tabs.length > 0 ? + tabs.sort((a, b) => b.id - a.id)[0] : + null ; + } + + static getLastPinnedTab(windowId) { // visible, pinned + return TabsStore.query({ + windowId, + tabs: TabsStore.pinnedTabsInWindow.get(windowId), + living: true, + ordered: true, + last: true + }); + } + + static getFirstUnpinnedTab(windowId) { // not-pinned + return TabsStore.query({ + windowId, + tabs: TabsStore.unpinnedTabsInWindow.get(windowId), + ordered: true + }); + } + + static getLastUnpinnedTab(windowId) { // not-pinned + return TabsStore.query({ + windowId, + tabs: TabsStore.unpinnedTabsInWindow.get(windowId), + ordered: true, + last: true + }); + } + + static getFirstNormalTab(windowId) { // visible, not-collapsed, not-pinned + return TabsStore.query({ + windowId, + tabs: TabsStore.unpinnedTabsInWindow.get(windowId), + normal: true, + ordered: true + }); + } + + static getGroupTabForOpener(opener) { + if (!opener) + return null; + TabsStore.assertValidTab(opener); + const groupTab = TabsStore.query({ + windowId: opener.windowId, + tabs: TabsStore.groupTabsInWindow.get(opener.windowId), + living: true, + attributes: [ + Constants.kCURRENT_URI, + new RegExp(`openerTabId=${opener.$TST.uniqueId.id}($|[#&])`) + ] + }); + if (!groupTab || + groupTab == opener || + groupTab.pinned == opener.pinned) + return null; + return groupTab; + } + + static getOpenerFromGroupTab(groupTab) { + if (!groupTab.$TST.isGroupTab) + return null; + TabsStore.assertValidTab(groupTab); + const openerTabId = (new URL(groupTab.url)).searchParams.get('openerTabId'); + const openerTab = Tab.getByUniqueId(openerTabId); + if (!openerTab || + openerTab == groupTab || + openerTab.pinned == groupTab.pinned) + return null; + return openerTab; + } + + static getSubstanceFromAliasGroupTab(groupTab) { + if (!groupTab.$TST.isGroupTab) + return null; + TabsStore.assertValidTab(groupTab); + const aliasTabId = (new URL(groupTab.url)).searchParams.get('aliasTabId'); + const aliasTab = Tab.getByUniqueId(aliasTabId); + if (!aliasTab || + aliasTab == groupTab || + aliasTab.pinned == groupTab.pinned) + return null; + return aliasTab; + } + + //=================================================================== + // grap tabs + //=================================================================== + + static getActiveTabs() { + return Array.from(TabsStore.activeTabInWindow.values(), TabsStore.ensureLivingItem); + } + + static getAllTabs(windowId = null, options = {}) { + return TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.livingTabsInWindow, windowId), + living: true, + ordered: true, + ...options + }); + } + + static getTabAt(windowId, index) { + const tabs = TabsStore.livingTabsInWindow.get(windowId); + const allTabs = TabsStore.windows.get(windowId).tabs; + return TabsStore.query({ + windowId, + tabs, + living: true, + fromIndex: Math.max(0, index - (allTabs.size - tabs.size)), + logicalIndex: index, + first: true + }); + } + + static getTabs(windowId = null, options = {}) { // only visible, including collapsed and pinned + return TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.controllableTabsInWindow, windowId), + controllable: true, + ordered: true, + ...options + }); + } + + static getTabsBetween(begin, end) { + if (!begin || !TabsStore.ensureLivingItem(begin) || + !end || !TabsStore.ensureLivingItem(end)) + throw new Error('getTabsBetween requires valid two tabs'); + if (begin.windowId != end.windowId) + throw new Error('getTabsBetween requires two tabs in same window'); + + if (begin == end) + return []; + if (begin.index > end.index) + [begin, end] = [end, begin]; + return TabsStore.queryAll({ + windowId: begin.windowId, + tabs: TabsStore.getTabsMap(TabsStore.controllableTabsInWindow, begin.windowId), + id: (id => id != begin.id && id != end.id), + fromId: begin.id, + toId: end.id + }); + } + + static getNormalTabs(windowId = null, options = {}) { // only visible, including collapsed, not pinned + return TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.unpinnedTabsInWindow, windowId), + normal: true, + ordered: true, + ...options + }); + } + + static getVisibleTabs(windowId = null, options = {}) { // visible, not-collapsed, not-hidden + return TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.visibleTabsInWindow, windowId), + living: true, + ordered: true, + ...options + }); + } + + static getHiddenTabs(windowId = null, options = {}) { + return TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.livingTabsInWindow, windowId), + living: true, + ordered: true, + hidden: true, + ...options + }); + } + + static getPinnedTabs(windowId = null, options = {}) { // visible, pinned + return TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.pinnedTabsInWindow, windowId), + living: true, + ordered: true, + ...options + }); + } + + static getUnpinnedTabs(windowId = null, options = {}) { // visible, not pinned + return TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.unpinnedTabsInWindow, windowId), + living: true, + ordered: true, + ...options + }); + } + + static getRootTabs(windowId = null, options = {}) { + return TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.rootTabsInWindow, windowId), + controllable: true, + ordered: true, + ...options + }); + } + + static getLastRootTab(windowId, options = {}) { + const tabs = Tab.getRootTabs(windowId, options); + return tabs[tabs.length - 1]; + } + + static collectRootTabs(tabs) { + const tabsSet = new Set(tabs); + return tabs.filter(tab => { + if (!TabsStore.ensureLivingItem(tab)) + return false; + const parent = tab.$TST.parent; + return !parent || !tabsSet.has(parent); + }); + } + + static getSubtreeCollapsedTabs(windowId = null, options = {}) { + return TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.subtreeCollapsableTabsInWindow, windowId), + living: true, + hidden: false, + ordered: true, + ...options + }); + } + + static getGroupTabs(windowId = null, options = {}) { + return TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.groupTabsInWindow, windowId), + living: true, + ordered: true, + ...options + }); + } + + static getLoadingTabs(windowId = null, options = {}) { + return TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.loadingTabsInWindow, windowId), + living: true, + ordered: true, + ...options + }); + } + + static getDraggingTabs(windowId = null, options = {}) { + return TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.draggingTabsInWindow, windowId), + living: true, + ordered: true, + ...options + }); + } + + static getRemovingTabs(windowId = null, options = {}) { + return TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.removingTabsInWindow, windowId), + ordered: true, + ...options + }); + } + + static getDuplicatingTabs(windowId = null, options = {}) { + return TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.duplicatingTabsInWindow, windowId), + living: true, + ordered: true, + ...options + }); + } + + static getHighlightedTabs(windowId = null, options = {}) { + return TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.highlightedTabsInWindow, windowId), + living: true, + ordered: true, + ...options + }); + } + + static getSelectedTabs(windowId = null, options = {}) { + const tabs = TabsStore.getTabsMap(TabsStore.selectedTabsInWindow, windowId); + const selectedTabs = TabsStore.queryAll({ + windowId, + tabs, + living: true, + ordered: true, + ...options + }); + const highlightedTabs = TabsStore.getTabsMap(TabsStore.highlightedTabsInWindow, windowId); + if (!highlightedTabs || + highlightedTabs.size < 2) + return selectedTabs; + + if (options.iterator) + return (function* () { + const alreadyReturnedTabs = new Set(); + for (const tab of selectedTabs) { + yield tab; + alreadyReturnedTabs.add(tab); + } + for (const tab of highlightedTabs.values()) { + if (!alreadyReturnedTabs.has(tab)) + yield tab; + } + })(); + else + return TreeItem.sort(Array.from(new Set([...selectedTabs, ...Array.from(highlightedTabs.values())]))); + } + + static getNeedToBeSynchronizedTabs(windowId = null, options = {}) { + return TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.unsynchronizedTabsInWindow, windowId), + visible: true, + ...options + }); + } + + static hasNeedToBeSynchronizedTab(windowId) { + return !!TabsStore.query({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.unsynchronizedTabsInWindow, windowId), + visible: true + }); + } + + static hasLoadingTab(windowId) { + return !!TabsStore.query({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.loadingTabsInWindow, windowId), + visible: true + }); + } + + static hasDuplicatedTabs(windowId, options = {}) { + const tabs = TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.livingTabsInWindow, windowId), + living: true, + ...options, + iterator: true + }); + const tabKeys = new Set(); + for (const tab of tabs) { + const key = `${tab.cookieStoreId}\n${tab.url}`; + if (tabKeys.has(key)) + return true; + tabKeys.add(key); + } + return false; + } + + static hasMultipleTabs(windowId, options = {}) { + const tabs = TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.livingTabsInWindow, windowId), + living: true, + ...options, + iterator: true + }); + let count = 0; + // eslint-disable-next-line no-unused-vars + for (const tab of tabs) { + count++; + if (count > 1) + return true; + } + return false; + } + + // "Recycled tab" is an existing but reused tab for session restoration. + static getRecycledTabs(windowId = null, options = {}) { + const userNewTabUrls = configs.guessNewOrphanTabAsOpenedByNewTabCommandUrl.split('|').map(part => sanitizeForRegExpSource(part.trim())).join('|'); + return TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.livingTabsInWindow, windowId), + living: true, + states: [Constants.kTAB_STATE_RESTORED, false], + attributes: [Constants.kCURRENT_URI, new RegExp(`^(|${userNewTabUrls}|about:newtab|about:blank|about:privatebrowsing)$`)], + ...options + }); + } + + //=================================================================== + // utilities + //=================================================================== + + static bufferedTooltipTextChanges = new Map(); + static broadcastTooltipText(tabs) { + if (!Constants.IS_BACKGROUND || + !Tab.broadcastTooltipText.enabled) + return; + + if (!Array.isArray(tabs)) + tabs = [tabs]; + + if (tabs.length == 0) + return; + + for (const tab of tabs) { + Tab.bufferedTooltipTextChanges.set(tab.id, { + windowId: tab.windowId, + tabId: tab.id, + high: tab.$TST.highPriorityTooltipText, + low: tab.$TST.lowPriorityTooltipText, + }); + } + + const triedAt = `${Date.now()}-${parseInt(Math.random() * 65000)}`; + Tab.broadcastTooltipText.triedAt = triedAt; + (Constants.IS_BACKGROUND ? + setTimeout : // because window.requestAnimationFrame is decelerate for an invisible document. + window.requestAnimationFrame)(() => { + if (Tab.broadcastTooltipText.triedAt != triedAt) + return; + + // Let's flush buffered changes! + const messageForWindows = new Map(); + for (const change of Tab.bufferedTooltipTextChanges.values()) { + const message = messageForWindows.get(change.windowId) || { + type: Constants.kCOMMAND_BROADCAST_TAB_TOOLTIP_TEXT, + windowId: change.windowId, + tabIds: [], + changes: [], + }; + message.tabIds.push(change.tabId); + message.changes.push(change); + } + for (const message of messageForWindows) { + SidebarConnection.sendMessage(message); + } + Tab.bufferedTooltipTextChanges.clear(); + }, 0); + } + + static bufferedStatesChanges = new Map(); + static broadcastState(tabs, { add, remove } = {}) { + if (!Constants.IS_BACKGROUND || + !Tab.broadcastState.enabled) + return; + + if (!Array.isArray(tabs)) + tabs = [tabs]; + + if (tabs.length == 0) + return; + + for (const tab of tabs) { + const message = Tab.bufferedStatesChanges.get(tab.id) || { + windowId: tab.windowId, + tabId: tab.id, + add: new Set(), + remove: new Set(), + }; + if (add) + for (const state of add) { + message.add.add(state); + message.remove.delete(state); + } + if (remove) + for (const state of remove) { + message.add.delete(state); + message.remove.add(state); + } + + Tab.bufferedStatesChanges.set(tab.id, message); + } + + const triedAt = `${Date.now()}-${parseInt(Math.random() * 65000)}`; + Tab.broadcastState.triedAt = triedAt; + (Constants.IS_BACKGROUND ? + setTimeout : // because window.requestAnimationFrame is decelerate for an invisible document. + window.requestAnimationFrame)(() => { + if (Tab.broadcastState.triedAt != triedAt) + return; + + // Let's flush buffered changes! + + // Unify buffered changes only if same type changes are consecutive. + // Otherwise the order of changes would be mixed and things may become broken. + const unifiedMessages = []; + let lastKey; + let unifiedMessage = null; + for (const message of Tab.bufferedStatesChanges.values()) { + const key = `${message.windowId}/add:${[...message.add]}/remove:${[...message.remove]}`; + if (key != lastKey) { + if (unifiedMessage) + unifiedMessages.push(unifiedMessage); + unifiedMessage = null; + } + lastKey = key; + unifiedMessage = unifiedMessage || { + type: Constants.kCOMMAND_BROADCAST_TAB_STATE, + windowId: message.windowId, + tabIds: new Set(), + add: message.add, + remove: message.remove, + }; + unifiedMessage.tabIds.add(message.tabId); + } + if (unifiedMessage) + unifiedMessages.push(unifiedMessage); + Tab.bufferedStatesChanges.clear(); + + // SidebarConnection.sendMessage() has its own bulk-send mechanism, + // so we don't need to bundle them like an array. + for (const message of unifiedMessages) { + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_BROADCAST_TAB_STATE, + windowId: message.windowId, + tabIds: [...message.tabIds], + add: [...message.add], + remove: [...message.remove], + }); + } + }, 0); + } + + static getOtherTabs(windowId, ignoreTabs, options = {}) { + const query = { + windowId: windowId, + tabs: TabsStore.livingTabsInWindow.get(windowId), + ordered: true + }; + if (Array.isArray(ignoreTabs) && + ignoreTabs.length > 0) + query['!id'] = ignoreTabs.map(tab => tab.id); + return TabsStore.queryAll({ ...query, ...options }); + }; + + static getIndex(tab, { ignoreTabs } = {}) { + if (!TabsStore.ensureLivingItem(tab)) + return -1; + TabsStore.assertValidTab(tab); + return Tab.getOtherTabs(tab.windowId, ignoreTabs).indexOf(tab); + } + + static calculateNewTabIndex({ insertAfter, insertBefore, ignoreTabs } = {}) { + // We need to calculate new index based on "insertAfter" at first, to avoid + // placing of the new tab after hidden tabs (too far from the location it + // should be.) + if (insertAfter) + return Tab.getIndex(insertAfter, { ignoreTabs }) + 1; + if (insertBefore) + return Tab.getIndex(insertBefore, { ignoreTabs }); + return -1; + } + + static async doAndGetNewTabs(asyncTask, windowId) { + const tabsQueryOptions = { + windowType: 'normal' + }; + if (windowId) { + tabsQueryOptions.windowId = windowId; + } + const beforeTabs = await browser.tabs.query(tabsQueryOptions).catch(ApiTabs.createErrorHandler()); + const beforeIds = mapAndFilterUniq(beforeTabs, tab => tab.id, { set: true }); + await asyncTask(); + const afterTabs = await browser.tabs.query(tabsQueryOptions).catch(ApiTabs.createErrorHandler()); + const addedTabs = mapAndFilter(afterTabs, + tab => !beforeIds.has(tab.id) && Tab.get(tab.id) || undefined); + return addedTabs; + } + + static dumpAll(windowId) { + if (!configs.debug) + return; + let output = 'dumpAllTabs'; + for (const tab of Tab.getAllTabs(windowId, {iterator: true })) { + output += '\n' + toLines([...tab.$TST.ancestors.reverse(), tab], + tab => `${tab.id}${tab.pinned ? ' [pinned]' : ''}`, + ' => '); + } + log(output); + } +} + + +const mWaitingTasks = new Map(); + +function destroyWaitingTabTask(task) { + const tasks = mWaitingTasks.get(task.tabId); + if (tasks) + tasks.delete(task); + + if (task.timeout) + clearTimeout(task.timeout); + + const resolve = task.resolve; + const stack = task.stack; + + task.tabId = undefined; + task.resolve = undefined; + task.timeout = undefined; + task.stack = undefined; + + return { resolve, stack }; +} + +function onWaitingTabTracked(tab) { + if (!tab) + return; + + const tasks = mWaitingTasks.get(tab.id); + if (!tasks) + return; + + mWaitingTasks.delete(tab.id); + + for (const task of tasks) { + tasks.delete(task); + const { resolve } = destroyWaitingTabTask(task); + if (!resolve) + continue; + resolve(tab); + } +} +TreeItem.onElementBound.addListener(onWaitingTabTracked); +Tab.onTracked.addListener(onWaitingTabTracked); + +function onWaitingTabDestroyed(tab) { + if (!tab) + return; + + const tasks = mWaitingTasks.get(tab.id); + if (!tasks) + return; + + mWaitingTasks.delete(tab.id); + + const scope = TabsStore.getCurrentWindowId() || 'bg'; + for (const task of tasks) { + tasks.delete(task); + const { resolve, stack } = destroyWaitingTabTask(task); + if (!resolve) + continue; + + log(`Tab.waitUntilTracked: ${tab.id} is destroyed while waiting (in ${scope})\n${stack}`); + resolve(null); + } +} +Tab.onDestroyed.addListener(onWaitingTabDestroyed); + +function onWaitingTabRemoved(removedTabId, _removeInfo) { + const tasks = mWaitingTasks.get(removedTabId); + if (!tasks) + return; + + mWaitingTasks.delete(removedTabId); + + const scope = TabsStore.getCurrentWindowId() || 'bg'; + for (const task of tasks) { + tasks.delete(task); + const { resolve, stack } = destroyWaitingTabTask(task); + if (!resolve) + continue; + + log(`Tab.waitUntilTracked: ${removedTabId} is removed while waiting (in ${scope})\n${stack}`); + resolve(null); + } +} +browser.tabs.onRemoved.addListener(onWaitingTabRemoved); + +async function waitUntilTracked(tabId, options = {}) { + if (!tabId) { + return null; + } + const stack = configs.debug && new Error().stack; + const tab = Tab.get(tabId); + if (tab) { + onWaitingTabTracked(tab); + if (options.element) + return tab.$TST.promisedElement; + return tab; + } + const tasks = mWaitingTasks.get(tabId) || new Set(); + const task = { + tabId, + stack, + }; + tasks.add(task); + mWaitingTasks.set(tabId, tasks); + return new Promise((resolve, _reject) => { + task.resolve = resolve; + task.timeout = setTimeout(() => { + const { resolve } = destroyWaitingTabTask(task); + if (resolve) { + log(`Tab.waitUntilTracked for ${tabId} is timed out (in ${TabsStore.getCurrentWindowId() || 'bg'})\b${stack}`); + resolve(null); + } + }, configs.maximumDelayUntilTabIsTracked); // Tabs.moveTabs() between windows may take much time + browser.tabs.get(tabId).catch(_error => null).then(tab => { + if (tab) { + if (Tab.get(tabId)) + onWaitingTabTracked(tab); + return; + } + const { resolve } = destroyWaitingTabTask(task); + if (resolve) { + log('waitUntilTracked was called for unexisting tab'); + resolve(null); + } + }); + }).then(() => destroyWaitingTabTask(task)); +} + +Tab.broadcastTooltipText.enabled = false; +Tab.broadcastState.enabled = false; + +// utility +TreeItem.get = item => { + if (!item) { + return null; + } + switch (item?.type) { + case TreeItem.TYPE_TAB: + return Tab.get(item.id); + + case TreeItem.TYPE_GROUP: + return TabGroup.get(item.id); + + case TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER: + return TabGroup.get(item.id).$TST.collapsedMembersCounterItem; + + default: + return TabGroup.get(item) || Tab.get(item); + } +}; diff --git a/waterfox/browser/components/sidebar/common/Window.js b/waterfox/browser/components/sidebar/common/Window.js new file mode 100644 index 000000000000..a77d4a248b83 --- /dev/null +++ b/waterfox/browser/components/sidebar/common/Window.js @@ -0,0 +1,303 @@ +/* +# 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'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; + +import { + log as internalLogger, + dumpTab, + configs +} from './common.js'; + +import * as TabsStore from './tabs-store.js'; + +function log(...args) { + internalLogger('common/Window', ...args); +} + +export default class Window { + constructor(windowId, tabGroups) { + const alreadyTracked = TabsStore.windows.get(windowId); + if (alreadyTracked) + return alreadyTracked; + + log(`window ${windowId} is newly tracked`); + + this.id = windowId; + this.tabs = new Map(); + this.tabGroups = new Map(); + if (tabGroups) { + this.initTabGroups(tabGroups); + } + this.order = []; + + this.containerElement = null; + this.containerClassList = null; + this.pinnedContainerElement = null; + + this.internalMovingTabs = new Map(); + this.alreadyMovedTabs = new Map(); + this.internalClosingTabs = new Set(); + this.keepDescendantsTabs = new Set(); + this.highlightingTabs = new Set(); + this.tabsToBeHighlightedAlone = new Set(); + this.internallyMovingTabsForUpdatedNativeTabGroups = new Set(); + + this.subTreeMovingCount = + this.subTreeChildrenMovingCount = + this.doingIntelligentlyCollapseExpandCount = + this.duplicatingTabsCount = 0; + + this.internallyFocusingTabs = new Set(); + this.internallyFocusingByMouseTabs = new Set(); + this.internallyFocusingSilentlyTabs = new Set(); + + this.preventToDetectTabBunchesUntil = Date.now() + configs.tabBunchesDetectionDelayOnNewWindow; + + this.openingTabs = new Set(); + + this.openedNewTabs = new Map(); + + this.bypassTabControlCount = 0; + this.toBeOpenedNewTabCommandTab = 0; + this.toBeOpenedTabsWithPositions = 0; + this.toBeOpenedTabsWithCookieStoreId = 0; + this.toBeOpenedOrphanTabs = 0; + + this.toBeAttachedTabs = new Set(); + this.toBeDetachedTabs = new Set(); + + this.lastRelatedTabs = new Map(); + this.previousLastRelatedTabs = new Map(); + + TabsStore.windows.set(windowId, this); + TabsStore.prepareIndexesForWindow(windowId); + + // We should initialize private properties with blank value for better performance with a fixed shape. + this.delayedDestroy = null; + } + initTabGroups(tabGroups) { + log(`initializing tabGroups of window ${this.id}: `, tabGroups); + this.tabGroups = new Map((tabGroups || []).map(group => [group.id, group])); + } + + destroy() { + for (const tab of this.tabs.values()) { + if (tab.$TST) + tab.$TST.destroy(); + } + this.tabs.clear(); + this.tabGroups.clear(); + TabsStore.windows.delete(this.id); + TabsStore.unprepareIndexesForWindow(this.id); + + if (this.containerElement) { + const element = this.containerElement; + if (element.parentNode && !element.hasChildNodes()) + element.parentNode.removeChild(element); + } + if (this.pinnedContainerElement) { + const element = this.element; + if (element.parentNode && !element.hasChildNodes()) + element.parentNode.removeChild(element); + } + this.unbindElements(); + + this.tabs = null; + this.tabGroups = null; + this.order = null; + this.id = null; + } + + clear() { + this.tabs.clear(); + this.order = []; + TabsStore.unprepareIndexesForWindow(this.id); + TabsStore.prepareIndexesForWindow(this.id); + this.clearLastRelatedTabs(); + } + + clearLastRelatedTabs() { + for (const openerId of this.lastRelatedTabs.keys()) { + const opener = this.tabs.get(openerId); + if (!opener) + continue; + opener.$TST.newRelatedTabsCount = 0; + } + this.lastRelatedTabs.clear(); + this.previousLastRelatedTabs.clear(); + } + + bindContainerElement(element) { + this.containerElement = element; + this.containerClassList = element.classList; + } + + bindPinnedContainerElement(element) { + this.pinnedContainerElement = element; + this.pinnedContainerClassList = element.classList; + } + + unbindElements() { + this.containerElement = null; + this.containerClassList = null; + this.pinnedContainerElement = null; + this.pinnedContainerClassList = null; + } + + getOrderedTabs(startId, endId, tabs) { + const orderedIds = this.sliceOrder(startId, endId); + tabs = tabs || this.tabs; + return (function*() { + for (const id of orderedIds) { + const tab = tabs.get(id); + if (tab) + yield tab; + } + }).call(this); + } + + getReversedOrderedTabs(startId, endId, tabs) { + const orderedIds = this.sliceOrder(startId, endId, this.order.slice(0).reverse()); + tabs = tabs || this.tabs; + return (function*() { + for (const id of orderedIds) { + const tab = tabs.get(id); + if (tab) + yield tab; + } + }).call(this); + } + + sliceOrder(startId, endId, orderedIds) { + if (!orderedIds) + orderedIds = this.order; + if (startId) { + if (!this.tabs.has(startId)) + return []; + orderedIds = orderedIds.slice(orderedIds.indexOf(startId)); + } + if (endId) { + if (!this.tabs.has(endId)) + return []; + orderedIds = orderedIds.slice(0, orderedIds.indexOf(endId) + 1); + } + return orderedIds; + } + + trackTab(tab) { + const alreadyTracked = TabsStore.tabs.get(tab.id); + if (alreadyTracked) + tab = alreadyTracked; + + if (this.delayedDestroy) { + clearTimeout(this.delayedDestroy); + this.delayedDestroy = null; + } + + const order = this.order; + if (this.tabs.has(tab.id)) { // already tracked: update + const prevState = tab.reindexedBy; + const index = order.indexOf(tab.id); + order.splice(index, 1); + order.splice(tab.index, 0, tab.id); + for (let i = Math.min(index, tab.index), maxi = Math.min(Math.max(index, tab.index) + 1, order.length); i < maxi; i++) { + const tab = this.tabs.get(order[i]); + if (!tab) + throw new Error(`Unknown tab: ${i}/${order[i]} (${order.join(', ')})`); + tab.index = i; + tab.reindexedBy = `Window.property.trackTab/update (${tab.index})`; + tab.$TST.invalidateCache(); + } + const parent = tab.$TST.parent; + if (parent) { + parent.$TST.sortAndInvalidateChildren(); + parent.$TST.invalidateCachedAncestors(); + } + log(`tab ${dumpTab(tab)} is re-tracked under the window ${this.id}: `, prevState, index, '=>', tab.reindexedBy, order.join(', ')); + } + else { // not tracked yet: add + this.tabs.set(tab.id, tab); + order.splice(tab.index, 0, tab.id); + for (let i = tab.index + 1, maxi = order.length; i < maxi; i++) { + const tab = this.tabs.get(order[i]); + if (!tab) + throw new Error(`Unknown tab: ${i}/${order[i]} (${order.join(', ')})`); + tab.index = i; + tab.reindexedBy = `Window.property.trackTab/new (${tab.index})`; + tab.$TST.invalidateCache(); + } + log(`tab ${dumpTab(tab)} is newly tracked under the window ${this.id}: `, order); + } + TabsStore.updateIndexesForTab(tab); + tab.$TST.invalidateCache(); + return tab; + } + + detachTab(tabId) { + const tab = TabsStore.tabs.get(tabId); + if (!tab) + return; + + TabsStore.removeTabFromIndexes(tab); + + tab.$TST.detach(); + this.tabs.delete(tabId); + const order = this.order; + const index = order.indexOf(tab.id); + if (index < 0) // the tab is not tracked yet! + return; + order.splice(index, 1); + if (this.tabs.size == 0) { + if (!TabsStore.getCurrentWindowId()) { // only in the background page - the sidebar has no need to destroy itself manually. + // the last tab can be removed with browser.tabs.closeWindowWithLastTab=false, + // so we should not destroy the window immediately. + if (this.delayedDestroy) + clearTimeout(this.delayedDestroy); + this.delayedDestroy = setTimeout(() => { + if (this.tabs && + this.tabs.size == 0) + this.destroy(); + }, (configs.collapseDuration, 1000) * 5); + } + } + else { + for (let i = index, maxi = order.length; i < maxi; i++) { + this.tabs.get(order[i]).index = i; + } + } + return tab; + } + + untrackTab(tabId) { + const tab = this.detachTab(tabId); + if (tab) + tab.$TST.destroy(); + } + + export(full) { + const tabs = []; + for (const tab of this.getOrderedTabs()) { + tabs.push(tab.$TST.export(full)); + } + return { + tabs, + tabGroups: [...this.tabGroups.values()].map(group => group.$TST.sanitized), + }; + } +} + +Window.onInitialized = new EventListenerManager(); + +Window.init = (windowId, tabGroups) => { + const win = TabsStore.windows.get(windowId) || new Window(windowId, tabGroups); + if (tabGroups && tabGroups.size != win.tabGroups.size) { + win.initTabGroups(tabGroups); + } + Window.onInitialized.dispatch(win); + return win; +} diff --git a/waterfox/browser/components/sidebar/common/api-tabs.js b/waterfox/browser/components/sidebar/common/api-tabs.js new file mode 100644 index 000000000000..ea19a513b8a8 --- /dev/null +++ b/waterfox/browser/components/sidebar/common/api-tabs.js @@ -0,0 +1,137 @@ +/* +# 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'; + +import { + log as internalLogger, + configs +} from './common.js'; + +function log(...args) { + internalLogger('common/api-tabs', ...args); +} + +export async function getIndexes(...queriedTabIds) { + log('getIndexes ', queriedTabIds); + if (queriedTabIds.length == 0) + return []; + + const indexes = await Promise.all(queriedTabIds.map((tabId) => { + return browser.tabs.get(tabId) + .catch(e => { + handleMissingTabError(e); + return null; + }); + })); + return indexes.map(tab => tab ? tab.index : -1); +} + +export function isMissingTabError(error) { + return ( + error && + error.message && + error.message.includes('Invalid tab ID:') + ); +} +export function handleMissingTabError(error) { + if (!isMissingTabError(error)) + throw error; + // otherwise, this error is caused from a tab already closed. + // we just ignore it. + //console.log('Invalid Tab ID error on: ' + error.stack); +} + +export function isUnloadedError(error) { + return ( + error && + error.message && + error.message.includes('can\'t access dead object') + ); +} +export function handleUnloadedError(error) { + if (!isUnloadedError(error)) + throw error; +} + +export function isMissingHostPermissionError(error) { + return ( + error && + error.message && + error.message.includes('Missing host permission for the tab') + ); +} +export function handleMissingHostPermissionError(error) { + if (!isMissingHostPermissionError(error)) + throw error; +} + +export function createErrorHandler(...handlers) { + const stack = configs.debug && new Error().stack; + return (error) => { + try { + if (handlers.length > 0) { + let unhandledCount = 0; + handlers.forEach(handler => { + try { + handler(error); + } + catch(_error) { + unhandledCount++; + } + }); + if (unhandledCount == handlers.length) // not handled + throw error; + } + else { + throw error; + } + } + catch(newError){ + if (!configs.debug) + throw newError; + if (error == newError) + console.log('Unhandled Error: ', error, stack); + else + console.log('Unhandled Error: ', error, newError, stack); + } + }; +} + +export function createErrorSuppressor(...handlers) { + const stack = configs.debug && new Error().stack; + return (error) => { + try { + if (handlers.length > 0) { + let unhandledCount = 0; + handlers.forEach(handler => { + try { + handler(error); + } + catch(_error) { + unhandledCount++; + } + }); + if (unhandledCount == handlers.length) // not handled + throw error; + } + else { + throw error; + } + } + catch(newError){ + if (error && + error.message && + error.message.indexOf('Could not establish connection. Receiving end does not exist.') == 0) + return; + if (!configs.debug) + return; + if (error == newError) + console.log('Unhandled Error: ', error, stack); + else + console.log('Unhandled Error: ', error, newError, stack); + } + }; +} diff --git a/waterfox/browser/components/sidebar/common/bookmark.js b/waterfox/browser/components/sidebar/common/bookmark.js new file mode 100644 index 000000000000..71aa1e90828c --- /dev/null +++ b/waterfox/browser/components/sidebar/common/bookmark.js @@ -0,0 +1,1376 @@ +/* +# 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'; + +import * as PlaceHolderParser from '/extlib/placeholder-parser.js'; +import RichConfirm from '/extlib/RichConfirm.js'; + +import { + log as internalLogger, + configs, + notify, + wait, + sha1sum, + sanitizeForHTMLText, + isLinux, + isRTL, +} from './common.js'; +import * as ApiTabs from './api-tabs.js'; +import * as TreeBehavior from './tree-behavior.js'; +import * as Constants from './constants.js'; +import * as ContextualIdentities from './contextual-identities.js'; +import * as Dialog from './dialog.js'; +import * as Permissions from './permissions.js'; +import * as UserOperationBlocker from './user-operation-blocker.js'; + +import { Tab } from '/common/TreeItem.js'; + +function log(...args) { + internalLogger('common/bookmarks', ...args); +} + +let mCreatingCount = 0; + +export async function getItemById(id) { + if (!id) + return null; + try { + const items = await browser.bookmarks.get(id).catch(ApiTabs.createErrorHandler()); + if (items.length > 0) + return items[0]; + } + catch(_error) { + } + return null; +} + +if (Constants.IS_BACKGROUND) { + // Dialog loaded in a popup cannot call privileged APIs, so the background script proxies such operations. + browser.runtime.onMessage.addListener((message, sender) => { + if (!message || + typeof message != 'object') + return; + + switch (message.type) { + case 'ws:get-bookmark-item-by-id': + return getItemById(message.id); + + case 'ws:get-bookmark-child-items': + return browser.bookmarks.getChildren(message.id || 'root________').catch(ApiTabs.createErrorHandler()); + + case 'ws:get-bookmark-ancestor-ids': + return (async () => { + const ancestorIds = []; + let item; + let lastId = message.id; + do { + item = await getItemById(lastId); + if (!item) + break; + ancestorIds.push(lastId = item.parentId); + } while (lastId != 'root________'); + return ancestorIds; + })(); + + case 'ws:create-new-bookmark-folder': + return (async () => { + const folder = await browser.bookmarks.create({ + type: 'folder', + title: browser.i18n.getMessage('bookmarkDialog_newFolder_defaultTitle'), + parentId: message.parentId, + ...(typeof message.index == 'number' ? { index: message.index } : {}), + }).catch(ApiTabs.createErrorHandler()); + return folder; + })(); + + case 'ws:update-bookmark-folder': + return browser.bookmarks.update(message.id, { + title: message.title, + }).catch(ApiTabs.createErrorHandler()); + + case 'ws:resize-bookmark-dialog-by': + return (async () => { + const win = await browser.windows.get(sender.tab.windowId); + return browser.windows.update(win.id, { + width: win.width + (message.width || 0), + height: win.height + (message.height || 0), + }); + })(); + } + }); +} + +// The base URL of style declarations embedded into a popup become an unprivileged URL, +// and it is different from the URL of this file and the base URL of this addon. +// Thus we need to load images with their complete URL. +export const FOLDER_CHOOSER_STYLE = ` + .parentIdChooserMiniContainer, + .parentIdChooserFullContainer { + --icon-size: 16px; + } + + .parentIdChooserMiniContainer { + display: flex; + flex-direction: row; + } + .parentIdChooserMini { + display: flex; + flex-grow: 1; + margin-inline-end: 0.2em; + max-width: calc(100% - 2em /* width of the showAllFolders button */ - 0.2em); + } + + .showAllFolders { + display: flex; + flex-grow: 0; + width: 2em; + } + + .showAllFolders::before { + -moz-context-properties: fill; + background: currentColor; + content: ""; + display: inline-block; + fill: currentColor; + height: var(--icon-size); + line-height: 1; + mask: url("${browser.runtime.getURL('/sidebar/styles/icons/ArrowheadDown.svg')}") no-repeat center / 60%; + max-height: var(--icon-size); + max-width: var(--icon-size); + transform-origin: 50% 50%; + width: var(--icon-size); + } + .showAllFolders.expanded::before { + transform: rotatez(180deg); + } + + .parentIdChooserFullContainer { + flex-direction: column; + flex-grow: 1; + flex-shrink: 1; + } + .parentIdChooserFullContainer:not(.expanded) { + display: none; + } + + .parentIdChooserFullContainer ul { + list-style: none; + margin-block: 0; + margin-inline: 0; + padding-block: 0; + padding-inline: 0; + } + + .parentIdChooserFullContainer ul.parentIdChooserFull { + max-height: 0; + overflow: visible; + } + + .parentIdChooserFullContainer li:not(.expanded) > ul { + display: none; + } + + .parentIdChooserFullTreeContainer { + border: 1px solid; + margin-block: 0.5em; + margin-inline: 0; + min-height: 10em; + display: flex; + flex-direction: column; + flex-grow: 1; + flex-shrink: 1; + overflow-y: auto; + } + + .parentIdChooserFull li { + list-style: none; + margin-block: 0; + margin-inline: 0; + padding-block: 0; + padding-inline: 0; + } + + .parentIdChooserFull li > label { + padding-block: 0.25em; + padding-inline: 0.25em; + white-space: nowrap; + display: flex; + user-select: none; + } + .parentIdChooserFull li > label:hover { + background: rgba(0, 0, 0, 0.15); + } + + .parentIdChooserFull .twisty { + height: 1em; + width: 1em; + } + .parentIdChooserFull li.noChild .twisty { + visibility: hidden; + } + .parentIdChooserFull li > label > .twisty { + order: 1; + } + .parentIdChooserFull li > label > .twisty::before { + -moz-context-properties: fill; + background: currentColor; + content: ""; + display: inline-block; + height: 1em; + line-height: 1; + mask: url("${browser.runtime.getURL('/sidebar/styles/icons/ArrowheadDown.svg')}") no-repeat center / 60%; + max-height: 1em; + max-width: 1em; + transform-origin: 50% 50%; + transform: rotatez(-90deg); + width: 1em;; + } + .rtl .parentIdChooserFull li > label > .twisty::before { + transform: rotatez(90deg); + } + .parentIdChooserFull li.expanded > label > .twisty::before { + transform: rotatez(0deg); + } + + .parentIdChooserFull li.focused > label { + color: highlightText; + background: highlight; + outline: 1px dotted; + } + .parentIdChooserFull li.chosen > label > .twisty::before { + background: highlightText; + } + + .parentIdChooserFull li > label::before { + -moz-context-properties: fill; + background: currentColor; + content: ""; + display: inline-block; + height: var(--icon-size); + line-height: 1; + mask: url("${browser.runtime.getURL('/resources/icons/folder-16.svg')}") no-repeat center / 60%; + max-height: var(--icon-size); + max-width: var(--icon-size); + order: 2; + width: var(--icon-size); + } + + .parentIdChooserFull li > label > * { + order: 3; + } + + .parentIdChooserFull li > label > .label-text { + overflow: hidden; + text-overflow: ellipsis + } + + li.editing > label > .label-text { + display: none; + } + + li.editing > label > input[type="text"] { + display: flex; + flex-grow: 1; + } +`; + +const DIALOG_STYLE = ` + .itemContainer { + align-items: stretch; + display: flex; + flex-direction: column; + margin-block: 0.2em; + margin-inline: 0; + text-align: start; + } + .itemContainer.last { + flex-grow: 1; + flex-shrink: 1; + } + + .itemContainer > label { + display: flex; + margin-block-end: 0.2em; + white-space: nowrap; + } + + .itemContainer > input[type="text"] { + display: flex; + } + .itemContainer.dialog > input[type="text"] { + min-width: 30em; + } + + ${FOLDER_CHOOSER_STYLE} +`; + +export async function bookmarkTab(tab, { parentId, showDialog } = {}) { + try { + if (!(await Permissions.isGranted(Permissions.BOOKMARKS))) + throw new Error('not permitted'); + } + catch(_e) { + notify({ + title: browser.i18n.getMessage('bookmark_notification_notPermitted_title'), + message: browser.i18n.getMessage(`bookmark_notification_notPermitted_message${isLinux() ? '_linux' : ''}`), + url: `moz-extension://${location.host}/options/options.html#bookmarksPermissionSection` + }); + return null; + } + const parent = ( + (await getItemById(parentId || configs.defaultBookmarkParentId)) || + (await getItemById(configs.$default.defaultBookmarkParentId)) + ); + + let title = tab.title; + let url = tab.url; + if (!parentId) + parentId = parent?.id; + if (showDialog) { + const windowId = tab.windowId; + const inline = location.pathname.startsWith('/sidebar/'); + const inlineClass = inline ? 'inline' : 'dialog'; + const BASE_ID = `dialog-${Date.now()}-${parseInt(Math.random() * 65000)}:`; + const dialogParams = { + content: ` + +
      + `.trim(), + async onShown(container, { initFolderChooser, parentId, inline, isRTL }) { + if (container.classList.contains('simulation')) + return; + container.classList.add('bookmark-dialog'); + const [defaultItem, rootItems] = await Promise.all([ + browser.runtime.sendMessage({ type: 'ws:get-bookmark-item-by-id', id: parentId }), + browser.runtime.sendMessage({ type: 'ws:get-bookmark-child-items' }) + ]); + initFolderChooser({ + defaultItem, + rootItems, + container, + inline, + isRTL, + }); + container.querySelector('[name="title"]').select(); + }, + inject: { + initFolderChooser, + parentId, + inline, + isRTL: isRTL(), + }, + buttons: [ + browser.i18n.getMessage('bookmarkDialog_accept'), + browser.i18n.getMessage('bookmarkDialog_cancel') + ] + }; + let result; + if (inline) { + try { + UserOperationBlocker.blockIn(windowId, { throbber: false }); + result = await RichConfirm.show(dialogParams); + } + catch(_error) { + result = { buttonIndex: -1 }; + } + finally { + UserOperationBlocker.unblockIn(windowId, { throbber: false }); + } + } + else { + result = await Dialog.show(await browser.windows.get(windowId), { + ...dialogParams, + modal: true, + type: 'dialog', + url: ((await Permissions.isGranted(Permissions.ALL_URLS)) ? null : '/resources/blank.html'), + title: browser.i18n.getMessage('bookmarkDialog_dialogTitle_single') + }); + } + if (result.buttonIndex != 0) + return null; + title = result.values[`${BASE_ID}title`]; + url = result.values[`${BASE_ID}url`]; + parentId = result.values[`${BASE_ID}parentId`]; + } + + mCreatingCount++; + const item = await browser.bookmarks.create({ + parentId, title, url + }).catch(ApiTabs.createErrorHandler()); + wait(150).then(() => { + mCreatingCount--; + }); + return item; +} + +export async function bookmarkTabs(tabs, { parentId, index, showDialog, title } = {}) { + try { + if (!(await Permissions.isGranted(Permissions.BOOKMARKS))) + throw new Error('not permitted'); + } + catch(_e) { + notify({ + title: browser.i18n.getMessage('bookmark_notification_notPermitted_title'), + message: browser.i18n.getMessage('bookmark_notification_notPermitted_message'), + url: `moz-extension://${location.host}/options/options.html#bookmarksPermissionSection` + }); + return null; + } + const now = new Date(); + const year = String(now.getFullYear()); + if (!title) + title = PlaceHolderParser.process(configs.bookmarkTreeFolderName, (name, _rawArgs, ...args) => { + switch (name.toLowerCase()) { + case 'any': + for (const arg of args) { + if (!!arg) + return arg; + } + return ''; + + case 'title': + return tabs[0].title; + + case 'group': + return tabs[0].isGroupTab ? tabs[0].title : ''; + + case 'url': + return tabs[0].url; + + case 'short_year': + case 'shortyear': + return year.slice(-2); + + case 'full_year': + case 'fullyear': + case 'year': + return year; + + case 'month': + return String(now.getMonth() + 1).padStart(2, '0'); + + case 'date': + return String(now.getDate()).padStart(2, '0'); + + case 'hour': + case 'hours': + return String(now.getHours()).padStart(2, '0'); + + case 'min': + case 'minute': + case 'minutes': + return String(now.getMinutes()).padStart(2, '0'); + + case 'sec': + case 'second': + case 'seconds': + return String(now.getSeconds()).padStart(2, '0'); + + case 'msec': + case 'millisecond': + case 'milliseconds': + return String(now.getSeconds()).padStart(3, '0'); + } + }); + const folderParams = { + type: 'folder', + title + }; + let parent; + if (parentId) { + parent = await getItemById(parentId); + if (index !== undefined) + folderParams.index = index; + } + else { + parent = await getItemById(configs.defaultBookmarkParentId); + } + if (!parent) + parent = await getItemById(configs.$default.defaultBookmarkParentId); + if (parent) + folderParams.parentId = parent.id; + + if (showDialog) { + const windowId = tabs[0].windowId; + const inline = location.pathname.startsWith('/sidebar/'); + const inlineClass = inline ? 'inline' : 'dialog'; + const BASE_ID = `dialog-${Date.now()}-${parseInt(Math.random() * 65000)}:`; + const dialogParams = { + content: ` + +
        + `.trim(), + async onShown(container, { initFolderChooser, parentId, inline, isRTL }) { + if (container.classList.contains('simulation')) + return; + container.classList.add('bookmark-dialog'); + const [defaultItem, rootItems] = await Promise.all([ + browser.runtime.sendMessage({ type: 'ws:get-bookmark-item-by-id', id: parentId }), + browser.runtime.sendMessage({ type: 'ws:get-bookmark-child-items' }) + ]); + initFolderChooser({ + defaultItem, + rootItems, + container, + inline, + isRTL, + }); + container.querySelector('[name="title"]').select(); + }, + inject: { + initFolderChooser, + parentId: folderParams.parentId, + inline, + isRTL: isRTL(), + }, + buttons: [ + browser.i18n.getMessage('bookmarkDialog_accept'), + browser.i18n.getMessage('bookmarkDialog_cancel') + ] + }; + let result; + if (inline) { + try { + UserOperationBlocker.blockIn(windowId, { throbber: false }); + result = await RichConfirm.show(dialogParams); + } + catch(_error) { + result = { buttonIndex: -1 }; + } + finally { + UserOperationBlocker.unblockIn(windowId, { throbber: false }); + } + } + else { + result = await Dialog.show(await browser.windows.get(windowId), { + ...dialogParams, + modal: true, + type: 'dialog', + url: ((await Permissions.isGranted(Permissions.ALL_URLS)) ? null : '/resources/blank.html'), + title: browser.i18n.getMessage('bookmarkDialog_dialogTitle_multiple') + }); + } + if (result.buttonIndex != 0) + return null; + folderParams.title = result.values[`${BASE_ID}title`]; + folderParams.parentId = result.values[`${BASE_ID}parentId`]; + } + + const toBeCreatedCount = tabs.length + 1; + mCreatingCount += toBeCreatedCount; + + const titles = getTitlesWithTreeStructure(tabs); + const folder = await browser.bookmarks.create(folderParams).catch(ApiTabs.createErrorHandler()); + for (let i = 0, maxi = tabs.length; i < maxi; i++) { + await browser.bookmarks.create({ + parentId: folder.id, + index: i, + title: titles[i], + url: tabs[i].url + }).catch(ApiTabs.createErrorSuppressor()); + } + + wait(150).then(() => { + mCreatingCount -= toBeCreatedCount; + }); + + return folder; +} + +function getTitlesWithTreeStructure(tabs) { + const minLevel = Math.min(...tabs.map(tab => parseInt(tab.$TST.getAttribute(Constants.kLEVEL) || '0'))); + const titles = []; + for (let i = 0, maxi = tabs.length; i < maxi; i++) { + const tab = tabs[i]; + const title = tab.title; + const level = parseInt(tab.$TST.getAttribute(Constants.kLEVEL) || '0') - minLevel; + let prefix = ''; + for (let j = 0; j < level; j++) { + prefix += '>'; + } + if (prefix) + titles.push(`${prefix} ${title}`); + else + titles.push(title.replace(/^>+ /, '')); // if the page title has marker-like prefix, we need to remove it. + } + return titles; +} + +// This large method have to contain everything required to simulate the folder +// chooser of the bookmark creation dialog. +// Bookmark creation dialog is loaded into a popup window and we use this large +// method to inject the behavior of the folder chooser. +export async function initFolderChooser({ rootItems, defaultItem, defaultValue, container, inline, isRTL } = {}) { + const miniList = container.querySelector('select.parentIdChooserMini'); + const fullList = container.querySelector('ul.parentIdChooserFull'); + const fullListFocusibleContainer = container.querySelector('.parentIdChooserFullTreeContainer'); + const fullContainer = container.querySelector('.parentIdChooserFullContainer'); + const expandeFullListButton = container.querySelector('.showAllFolders'); + const newFolderButton = container.querySelector('.newFolder'); + + const BASE_ID = `folderChooser-${Date.now()}-${parseInt(Math.random() * 65000)}:`; + + const ensureItemVisible = item => { + const itemRect = item.querySelector('label').getBoundingClientRect(); + const containerRect = fullListFocusibleContainer.getBoundingClientRect(); + if (itemRect.top < containerRect.top) { + fullListFocusibleContainer.scrollBy(0, itemRect.top - containerRect.top - (itemRect.height / 2)); + } + else if (itemRect.bottom > containerRect.bottom) { + fullListFocusibleContainer.scrollBy(0, itemRect.bottom - containerRect.bottom + (itemRect.height / 2)); + } + }; + + const cancelEvent = event => { + event.stopImmediatePropagation(); + event.preventDefault(); + }; + + //========================================================================== + // Initialize mini chooser + //========================================================================== + for (const rootItem of rootItems) { + const item = miniList.appendChild(document.createElement('option')); + item.textContent = rootItem.title; + item.value = rootItem.id; + } + + miniList.appendChild(document.createElement('hr')); + const expanderOption = miniList.appendChild(document.createElement('option')); + expanderOption.textContent = browser.i18n.getMessage('bookmarkDialog_showAllFolders_label'); + expanderOption.setAttribute('value', `${BASE_ID}expandChooser`); + + miniList.appendChild(document.createElement('hr')); + const lastChosenOption = miniList.appendChild(document.createElement('option')); + + let lastChosenItem = defaultItem || + defaultValue && await getItemById(defaultValue) || + null; + const getLastChosenItem = () => { + return lastChosenItem || miniList.firstChild.$item || null; + }; + + const updateLastChosenOption = () => { + if (lastChosenItem) { + lastChosenOption.value = lastChosenItem.id; + lastChosenOption.textContent = lastChosenItem.title; + lastChosenOption.style.display = ''; + } + else { + lastChosenOption.style.display = 'none'; + } + miniList.value = getLastChosenItem().id; + }; + updateLastChosenOption(); + + let expanded = false; + let fullChooserHeight = 0; + const toggleFullChooser = async () => { + expanded = !expanded; + fullContainer.classList.toggle('expanded', expanded); + expandeFullListButton.classList.toggle('expanded', expanded); + if (!inline) { + const fullContainerStyle = window.getComputedStyle(fullContainer, null); + fullChooserHeight = Math.max( + fullChooserHeight, + Math.ceil(fullContainer.offsetHeight + + parseFloat(fullContainerStyle.getPropertyValue('margin-block-start')) + + parseFloat(fullContainerStyle.getPropertyValue('margin-block-end'))), + 150 + ); + await browser.runtime.sendMessage({ + type: 'ws:resize-bookmark-dialog-by', + width: 0, + height: expanded ? fullChooserHeight : -fullChooserHeight, + }); + } + if (lastChosenItem) { + const item = fullList.querySelector(`li[data-id="${lastChosenItem.id}"]`); + if (item) + ensureItemVisible(item); + } + }; + + //========================================================================== + // Initialize expander + //========================================================================== + const getElementTarget = event => { + return event.target.nodeType == Node.ELEMENT_NODE ? + event.target : + event.target.parentNode;; + }; + + //========================================================================== + // Initialize full chooser + //========================================================================== + fullList.level = 0; + + const exitAllEditings = () => { + for (const item of fullList.querySelectorAll('li.editing')) { + item.$exitTitleEdit(); + } + }; + + const getTargetItem = event => { + const elementTarget = getElementTarget(event); + return elementTarget?.closest('li'); + }; + + const focusToItem = item => { + if (!item) + return; + + exitAllEditings(); + + for (const oldFocused of fullListFocusibleContainer.querySelectorAll('.focused')) { + if (oldFocused == item) + continue; + oldFocused.classList.remove('focused'); + } + item.classList.add('focused'); + lastChosenItem = item.$item; + + ensureItemVisible(item); + updateLastChosenOption(); + }; + + const toggleItemExpanded = item => { + if (!item) + return; + + item.classList.toggle('expanded'); + if (item.classList.contains('expanded')) + item.$completeFolderItem(); + + focusToItem(item); + }; + + const expandOrDigIn = (event, focusedItem) => { + if (!focusedItem.classList.contains('expanded')) { + focusedItem.classList.add('expanded'); + focusedItem.$completeFolderItem(); + } + else { + const firstChild = focusedItem.querySelector('li'); + if (firstChild) + focusToItem(firstChild); + } + }; + const collapseOrDigOut = (event, focusedItem) => { + if (focusedItem.classList.contains('expanded')) { + focusedItem.classList.remove('expanded'); + } + else { + const nearestAncestor = focusedItem.parentNode.closest('li'); + if (nearestAncestor) + focusToItem(nearestAncestor); + } + }; + + const createNewSubFolder = async () => { + const folder = await browser.runtime.sendMessage({ + type: 'ws:create-new-bookmark-folder', + parentId: getLastChosenItem().id, + }); + const parentItem = fullList.querySelector(`li[data-id="${folder.parentId}"]`); + if (!parentItem) + return; + parentItem.$invalidate(); + parentItem.classList.add('expanded'); + await parentItem.$completeFolderItem(); + const folderItem = parentItem.querySelector(`li[data-id="${folder.id}"]`); + if (!folderItem) + return; + + focusToItem(folderItem); + folderItem.$enterTitleEdit(); + }; + + const generateFolderItem = (folder, level) => { + const item = document.createElement('li'); + item.$item = folder; + item.setAttribute('data-id', folder.id); + const title = folder.title || browser.i18n.getMessage('bookmarkFolderChooser_blank'); + const label = item.appendChild(document.createElement('label')); + label.setAttribute('style', `padding-inline-start: calc(1.25em * ${level} + 0.25em);`); + label.setAttribute('title', title); + const twisty = label.appendChild(document.createElement('span')); + twisty.setAttribute('class', 'twisty'); + const text = label.appendChild(document.createElement('div')); + text.setAttribute('class', 'label-text'); + text.textContent = title; + return item; + }; + + const buildItems = async (items, container) => { + const createdItems = []; + for (const item of items) { + if (item.type == 'bookmark' && + /^place:parent=([^&]+)$/.test(item.url)) { // alias for special folders + const realItem = await browser.runtime.sendMessage({ + type: 'ws:get-bookmark-item-by-id', + id: RegExp.$1 + }); + item.id = realItem.id; + item.type = realItem.type; + item.title = realItem.title; + } + if (item.type != 'folder') + continue; + + if (container.querySelector(`li[data-id="${item.id}"]`)) + continue; + + const folderItem = generateFolderItem(item, container.level); + container.insertBefore(folderItem, 'index' in item ? container.childNodes[item.index] : null); + createdItems.push(folderItem); + folderItem.$completeFolderItem = async () => { + if (!item.$fetched) { + item.$fetched = true; + item.children = (await browser.runtime.sendMessage({ + type: 'ws:get-bookmark-child-items', + id: item.id + })).filter(item => item?.type == 'folder'); + } + folderItem.classList.toggle('noChild', !item.children || item.children.length == 0); + if (item.children && + item.children.length > 0) { + let subFolderContainer = folderItem.querySelector('ul');; + if (!subFolderContainer) { + subFolderContainer = folderItem.appendChild(document.createElement('ul')); + subFolderContainer.level = container.level + 1; + } + await buildItems(item.children, subFolderContainer); + } + return folderItem; + }; + folderItem.$invalidate = () => { + item.$fetched = false; + }; + let titleField; + folderItem.$enterTitleEdit = async () => { + exitAllEditings(); + if (!titleField) { + const label = folderItem.querySelector('label'); + folderItem.classList.add('editing'); + titleField = label.appendChild(document.createElement('input')); + titleField.setAttribute('type', 'text'); + label.appendChild(titleField); + titleField.value = item.title || browser.i18n.getMessage('bookmarkFolderChooser_blank'); + } + titleField.focus(); + titleField.select(); + }; + folderItem.$exitTitleEdit = async () => { + if (!titleField) + return; + browser.runtime.sendMessage({ + type: 'ws:update-bookmark-folder', + id: item.id, + title: titleField.value, + }); + item.title = + folderItem.querySelector('.label-text').textContent = titleField.value; + folderItem.querySelector('label').setAttribute('title', titleField.value); + if (lastChosenItem?.id == item.id) + lastChosenItem.title = item.title; + titleField.parentNode.removeChild(titleField); + titleField = null; + folderItem.classList.remove('editing'); + updateLastChosenOption(); + }; + } + return createdItems; + }; + + const topLevelItems = await buildItems(rootItems, fullList); + + // Expand deeply nested tree until the chosen folder + let itemToBeFocused = topLevelItems.length > 0 && topLevelItems[0]; + if (lastChosenItem) { + const ancestorIds = await browser.runtime.sendMessage({ + type: 'ws:get-bookmark-ancestor-ids', + id: lastChosenItem.id, + }); + for (const id of [...ancestorIds.reverse(), lastChosenItem.id]) { + if (id == 'root________') + continue; + + const item = fullList.querySelector(`li[data-id="${id}"]`); + if (!item) + break; + + itemToBeFocused = item; + item.classList.add('expanded'); + await item.$completeFolderItem(); + } + } + if (itemToBeFocused) + itemToBeFocused.classList.add('focused'); + + + //========================================================================== + // UI events handling + //========================================================================== + container.addEventListener('focus', event => { + if (!getElementTarget(event)?.closest('input[type="text"], .parentIdChooserFullTreeContainer')) + exitAllEditings(); + }, { capture: true }); + container.addEventListener('blur', event => { + if (getElementTarget(event)?.closest('input[type="text"]')) { + const editingItem = fullList.querySelector('li.editing'); + if (editingItem) + editingItem.$exitTitleEdit(); + } + }, { capture: true }); + + miniList.addEventListener('change', () => { + if (miniList.value == `${BASE_ID}expandChooser`) { + if (!fullContainer.classList.contains('expanded')) + toggleFullChooser(); + miniList.value = getLastChosenItem().id; + return; + } + + const fullListItem = fullList.querySelector(`li[data-id="${miniList.value}"]`); + if (fullListItem) + focusToItem(fullListItem); + }); + + expandeFullListButton.addEventListener('click', event => { + if (event.button != 0) + return; + toggleFullChooser(); + }); + expandeFullListButton.addEventListener('keydown', event => { + const elementTarget = getElementTarget(event); + if (elementTarget != expandeFullListButton) + return; + + switch (event.key) { + case 'Enter': + cancelEvent(event); + case 'Space': + toggleFullChooser(); + break; + + default: + break; + } + }, { capture: true }); + + fullListFocusibleContainer.addEventListener('dblclick', event => { + if (event.button != 0) + return; + if (getElementTarget(event)?.closest('.twisty')) + return; + const item = getTargetItem(event); + if (item) + item.$enterTitleEdit(); + }); + fullListFocusibleContainer.addEventListener('click', event => { + if (event.button != 0) + return; + const target = getElementTarget(event); + if (target?.closest('.twisty')) { + toggleItemExpanded(getTargetItem(event)); + } + else if (!target?.closest('input[type="text"]')) { + focusToItem(getTargetItem(event)); + } + }); + fullListFocusibleContainer.addEventListener('keydown', event => { + if (getElementTarget(event)?.closest('input[type="text"]') && + event.key != 'Enter') + return; + + const focusibleItems = [...fullList.querySelectorAll('li:not(li:not(.expanded) li)')]; + const focusedItem = fullList.querySelector('li.focused'); + const index = focusedItem ? focusibleItems.indexOf(focusedItem) : -1; + switch (event.key) { + case 'Enter': + cancelEvent(event); + if (focusedItem?.matches('.editing')) + focusedItem.$exitTitleEdit(); + toggleItemExpanded(focusedItem); + break; + + case 'ArrowUp': { + cancelEvent(event); + const toBeFocused = focusibleItems[(index == 0 ? focusibleItems.length : index) - 1]; + focusToItem(toBeFocused); + }; break; + + case 'ArrowDown': { + cancelEvent(event); + const toBeFocused = focusibleItems[index == focusibleItems.length - 1 ? 0 : index + 1]; + focusToItem(toBeFocused); + }; break; + + case 'ArrowRight': + cancelEvent(event); + if (isRTL) + collapseOrDigOut(event, focusedItem); + else + expandOrDigIn(event, focusedItem); + break; + + case 'ArrowLeft': + cancelEvent(event); + if (isRTL) + expandOrDigIn(event, focusedItem); + else + collapseOrDigOut(event, focusedItem); + break; + + case 'PageUp': { + cancelEvent(event); + const toBeFocusedIndex = Math.min(focusibleItems.length - 1, Math.max(0, index - Math.floor(fullListFocusibleContainer.offsetHeight / focusedItem.offsetHeight) + 1)); + const toBeFocused = focusibleItems[toBeFocusedIndex]; + focusToItem(toBeFocused); + }; break; + + case 'PageDown': { + cancelEvent(event); + const toBeFocusedIndex = Math.min(focusibleItems.length - 1, Math.max(0, index + Math.floor(fullListFocusibleContainer.offsetHeight / focusedItem.offsetHeight) - 1)); + const toBeFocused = focusibleItems[toBeFocusedIndex]; + focusToItem(toBeFocused); + }; break; + + case 'Home': + cancelEvent(event); + focusToItem(focusibleItems[0]); + break; + + case 'End': + cancelEvent(event); + focusToItem(focusibleItems[focusibleItems.length - 1]); + break; + } + }, { capture: true }); + + newFolderButton.addEventListener('click', event => { + if (event.button != 0) + return; + createNewSubFolder(); + }); + newFolderButton.addEventListener('keydown', event => { + const elementTarget = getElementTarget(event); + if (elementTarget != newFolderButton) + return; + + switch (event.key) { + case 'Enter': + cancelEvent(event); + case 'Space': + createNewSubFolder(); + break; + + default: + break; + } + }, { capture: true }); +} + +let mCreatedBookmarks = []; +let mIsTracking = false; + +async function onBookmarksCreated(id, bookmark) { + if (!mIsTracking) + return; + + log('onBookmarksCreated ', { id, bookmark }); + + if (mCreatingCount > 0) + return; + + mCreatedBookmarks.push(bookmark); + reserveToGroupCreatedBookmarks(); +} + +function reserveToGroupCreatedBookmarks() { + if (reserveToGroupCreatedBookmarks.reserved) + clearTimeout(reserveToGroupCreatedBookmarks.reserved); + reserveToGroupCreatedBookmarks.reserved = setTimeout(() => { + reserveToGroupCreatedBookmarks.reserved = null; + tryGroupCreatedBookmarks(); + }, 250); +} +reserveToGroupCreatedBookmarks.reserved = null; +reserveToGroupCreatedBookmarks.retryCount = 0; + +async function tryGroupCreatedBookmarks() { + log('tryGroupCreatedBookmarks ', mCreatedBookmarks); + + if (!configs.autoCreateFolderForBookmarksFromTree) { + log(' => autoCreateFolderForBookmarksFromTree is false'); + return; + } + + const lastDraggedTabs = configs.lastDraggedTabs; + if (lastDraggedTabs && + lastDraggedTabs.tabIds.length > mCreatedBookmarks.length) { + if (reserveToGroupCreatedBookmarks.retryCount++ < 10) { + return reserveToGroupCreatedBookmarks(); + } + else { + reserveToGroupCreatedBookmarks.retryCount = 0; + mCreatedBookmarks = []; + configs.lastDraggedTabs = null; + log(' => timeout'); + return; + } + } + reserveToGroupCreatedBookmarks.retryCount = 0; + + const bookmarks = mCreatedBookmarks; + mCreatedBookmarks = []; + if (lastDraggedTabs) { + // accept only bookmarks from dragged tabs + const digest = await sha1sum(bookmarks.map(tab => tab.url).join('\n')); + configs.lastDraggedTabs = null; + if (digest != lastDraggedTabs.urlsDigest) { + log(' => digest mismatched ', { digest, last: lastDraggedTabs.urlsDigest }); + return; + } + } + + if (bookmarks.length < 2) { + log(' => ignore single bookmark'); + return; + } + + { + // Do nothing if multiple bookmarks are created under + // multiple parent folders by sync. + const parentIds = new Set(); + for (const bookmark of bookmarks) { + parentIds.add(bookmark.parentId); + } + log('parentIds: ', parentIds); + if (parentIds.size > 1) { + log(' => ignore bookmarks created under multiple folders'); + return; + } + } + + const tabs = lastDraggedTabs ? + lastDraggedTabs.tabIds.map(id => Tab.get(id)) : + (await Promise.all(bookmarks.map(async bookmark => { + const tabs = await browser.tabs.query({ url: bookmark.url }); + if (tabs.length == 0) + return null; + const tab = tabs.find(tab => tab.highlighted) || tabs[0]; + return Tab.get(tab); + }))).filter(tab => !!tab); + log('tabs: ', tabs); + if (tabs.length != bookmarks.length) { + log(' => ignore bookmarks created from non-tab sources'); + return; + } + + const treeStructure = TreeBehavior.getTreeStructureFromTabs(tabs); + log('treeStructure: ', treeStructure); + const topLevelTabsCount = treeStructure.filter(item => item.parent < 0).length; + if (topLevelTabsCount == treeStructure.length) { + log(' => no need to group bookmarks from dragged flat tabs'); + return; + } + + let titles = getTitlesWithTreeStructure(tabs); + if (tabs[0].$TST.isGroupTab && + titles.filter(title => !/^>/.test(title)).length == 1) { + log('delete needless bookmark for a group tab'); + browser.bookmarks.remove(bookmarks[0].id); + tabs.shift(); + bookmarks.shift(); + titles = getTitlesWithTreeStructure(tabs); + } + log('titles: ', titles); + + log('save tree structure to bookmarks'); + for (let i = 0, maxi = bookmarks.length; i < maxi; i++) { + const title = titles[i]; + if (title == tabs[i].title) + continue; + browser.bookmarks.update(bookmarks[i].id, { title }); + } + + log('ready to group bookmarks under a folder'); + + const parentId = bookmarks[0].parentId; + { + // Do nothing if all bookmarks are created under a new + // blank folder. + const allChildren = await browser.bookmarks.getChildren(parentId); + log('allChildren.length vs bookmarks.length: ', allChildren.length, bookmarks.length); + if (allChildren.length == bookmarks.length) { + log(' => no need to create folder for bookmarks under a new blank folder'); + return; + } + } + + log('create a folder for grouping'); + mCreatingCount++; + const folder = await browser.bookmarks.create({ + type: 'folder', + title: bookmarks[0].title, + index: bookmarks[0].index, + parentId + }).catch(ApiTabs.createErrorHandler()); + wait(150).then(() => { + mCreatingCount--; + }); + + log('move into a folder'); + let movedCount = 0; + for (const bookmark of bookmarks) { + await browser.bookmarks.move(bookmark.id, { + parentId: folder.id, + index: movedCount++ + }); + } +} + +if (Constants.IS_BACKGROUND && + browser.bookmarks && + browser.bookmarks.onCreated) { // already granted + browser.bookmarks.onCreated.addListener(onBookmarksCreated); + mIsTracking = true; +} + +export async function startTracking() { + if (!mIsTracking || + !Constants.IS_BACKGROUND) + return; + + mIsTracking = true; + const granted = await Permissions.isGranted(Permissions.BOOKMARKS); + if (granted && !browser.bookmarks.onCreated.hasListener(onBookmarksCreated)) + browser.bookmarks.onCreated.addListener(onBookmarksCreated); +} + + +export const BOOKMARK_TITLE_DESCENDANT_MATCHER = /^(>+) /; + +export async function getTreeStructureFromBookmarkFolder(folderOrId) { + const items = folderOrId.children || await browser.bookmarks.getChildren(folderOrId.id || folderOrId); + return getTreeStructureFromBookmarks(items.filter(item => item.type == 'bookmark')); +} + +export function getTreeStructureFromBookmarks(items) { + const lastItemIndicesWithLevel = new Map(); + let lastMaxLevel = 0; + return items.reduce((result, item, index) => { + const { cookieStoreId, url } = ContextualIdentities.getIdFromBookmark(item); + if (cookieStoreId) { + item.cookieStoreId = cookieStoreId; + if (url) + item.url = url; + } + + let level = 0; + if (lastItemIndicesWithLevel.size > 0 && + item.title.match(BOOKMARK_TITLE_DESCENDANT_MATCHER)) { + level = RegExp.$1.length; + if (level - lastMaxLevel > 1) { + level = lastMaxLevel + 1; + } + else { + while (lastMaxLevel > level) { + lastItemIndicesWithLevel.delete(lastMaxLevel--); + } + } + lastItemIndicesWithLevel.set(level, index); + lastMaxLevel = level; + result.push(lastItemIndicesWithLevel.get(level - 1) - lastItemIndicesWithLevel.get(0)); + item.title = item.title.replace(BOOKMARK_TITLE_DESCENDANT_MATCHER, '') + } + else { + result.push(-1); + lastItemIndicesWithLevel.clear(); + lastItemIndicesWithLevel.set(0, index); + } + return result; + }, []); +} + + diff --git a/waterfox/browser/components/sidebar/common/browser-theme.js b/waterfox/browser/components/sidebar/common/browser-theme.js new file mode 100644 index 000000000000..b88532a1296a --- /dev/null +++ b/waterfox/browser/components/sidebar/common/browser-theme.js @@ -0,0 +1,201 @@ +/* +# 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'; + +import { + configs +} from '/common/common.js'; +import * as Color from './color.js'; +import * as Constants from './constants.js'; + +export function generateThemeRules(theme) { + const rules = []; + const generateCustomRule = (theme, prefix = '') => { + for (const key of Object.keys(theme)) { + if (!theme[key]) + continue; + const propertyKey = prefix ? `${prefix}-${key}` : key; + let value = theme[key]; + switch (typeof theme[key]) { + case 'object': + generateCustomRule(value, propertyKey); + break; + case 'string': + if (/^[^:]+:\/\//.test(value)) + value = `url(${JSON.stringify(value)})`; + rules.push(`--theme-${propertyKey}: ${value};`); + for (let alpha = 10; alpha < 100; alpha += 10) { + rules.push(`--theme-${propertyKey}-${alpha}: ${Color.overrideCSSAlpha(value, alpha / 100)};`); + } + break; + } + } + }; + generateCustomRule(theme); + return rules.join('\n'); +} + +export async function generateThemeDeclarations(theme) { + if (!theme || + !theme.colors) { + return ` + :root { + /* https://searchfox.org/mozilla-central/rev/0c7c41109902cb8967ec3ef2c0ddb326701cfbee/browser/themes/windows/browser.css#15 */ + /* https://searchfox.org/mozilla-central/rev/0c7c41109902cb8967ec3ef2c0ddb326701cfbee/browser/themes/linux/browser.css#20 */ + --non-lwt-selected-tab-background-color-proton: rgba(255, 255, 255, 0.15); + }`; + } + + const extraColors = []; + const themeFrameColor = theme.colors.frame || theme.colors.accentcolor /* old name */; + const inactiveTextColor = theme.colors.tab_background_text || theme.colors.textcolor /* old name */; + const activeTextColor = theme.colors.tab_text || theme.colors.bookmark_text || theme.colors.toolbar_text /* old name */ || inactiveTextColor; + let bgAlpha = 1; + let hasImage = false; + if (theme.images) { + const isRightside = configs.sidebarPosition == Constants.kTABBAR_POSITION_RIGHT; + const images = []; + const frameImage = theme.images.theme_frame || theme.images.headerURL /* old name */; + if (frameImage) { + hasImage = true; + // https://searchfox.org/mozilla-central/rev/532e4b94b9e807d157ba8e55034aef05c1196dc9/browser/themes/shared/tabs.inc.css#537 + extraColors.push('--browser-bg-hover-for-header-image: rgba(0, 0, 0, 0.1);'); + // https://searchfox.org/mozilla-central/rev/532e4b94b9e807d157ba8e55034aef05c1196dc9/browser/base/content/browser.css#20 + extraColors.push('--browser-bg-active-for-header-image: rgba(255, 255, 255, 0.4)'); + // https://searchfox.org/mozilla-central/rev/532e4b94b9e807d157ba8e55034aef05c1196dc9/toolkit/themes/windows/global/global.css#138 + if (Color.isBrightColor(inactiveTextColor)) { + // for bright text + extraColors.push('--browser-textshadow-for-header-image: 1px 1px 1.5px black'); + } + else { + // for dark text + extraColors.push('--browser-textshadow-for-header-image: 0 0 1.5px white'); + } + images.push({ + url: frameImage, + position: isRightside ? 'top right' : 'top left', + repeat: 'no-repeat', + }); + } + + const positions = theme.properties?.additional_backgrounds_alignment || []; + const repeats = theme.properties?.additional_backgrounds_tiling || []; + if (Array.isArray(theme.images.additional_backgrounds) && + theme.images.additional_backgrounds.length > 0) { + const leftImageCount = positions.filter(position => position.includes('left')).length; + const rightImageCount = positions.filter(position => position.includes('right')).length; + const repeatableImageCount = repeats.filter(repeat => repeat != 'no-repeat').length; + for (let i = 0, maxi = theme.images.additional_backgrounds.length; i < maxi; i++) { + const image = theme.images.additional_backgrounds[i]; + const position = positions.length > 0 && positions[Math.min(i, positions.length - 1)] || 'default'; + const repeat = repeats.length > 0 && repeats[Math.min(i, repeats.length - 1)] || 'default'; + if (repeatableImageCount > 0 && + repeat.includes('no-repeat')) + continue; + if (position && + position.includes('right') != isRightside && + repeat == 'no-repeat' && + leftImageCount > 0 && + rightImageCount > 0) + continue; + images.push({ + url: image, + position, + repeat, + size: repeat == 'reepat-y' ? 'auto' : 'auto 100%', + }); + } + bgAlpha = 0.75; + hasImage = true; + } + + await Promise.all(images.map(async image => { + if (image.size) + return; + + const loader = new Image(); + try { + const shouldRepeat = ( + theme.properties && + Array.isArray(theme.properties.additional_backgrounds_tiling) && + theme.properties.additional_backgrounds_tiling.some(value => value == 'repeat' || value == 'repeat-y') + ); + const shouldNoRepeat = ( + !theme.properties || + !Array.isArray(theme.properties.additional_backgrounds_tiling) || + theme.properties.additional_backgrounds_tiling.some(value => value == 'no-repeat') + ); + let maybeRepeatable = false; + if (!shouldRepeat && !shouldNoRepeat) { + await new Promise((resolve, reject) => { + loader.addEventListener('load', resolve); + loader.addEventListener('error', reject); + loader.src = image; + }); + maybeRepeatable = (loader.width / Math.max(1, loader.height)) <= configs.unrepeatableBGImageAspectRatio; + } + if (shouldNoRepeat) + image.size = 'cover'; + else if (shouldRepeat || maybeRepeatable) + image.size = 'auto'; + } + catch(error) { + console.error(error); + } + })); + + if (hasImage) { + extraColors.push('--browser-bg-images: ' + images.map(image => `url(${JSON.stringify(image.url)})`).join(',')); + extraColors.push('--browser-bg-position: ' + images.map(image => image.position).join(',')); + extraColors.push('--browser-bg-repeat: ' + images.map(image => image.repeat).join(',')); + extraColors.push('--browser-bg-size: ' + images.map(image => image.size).join(',')); + } + } + const themeBaseColor = Color.overrideCSSAlpha(themeFrameColor, bgAlpha); + let toolbarColor = Color.mixCSSColors(themeBaseColor, 'rgba(255, 255, 255, 0.4)', bgAlpha); + if (theme.colors.toolbar) { + if (hasImage) { + extraColors.push(`--browser-bg-for-header-image: ${theme.colors.toolbar};`); + toolbarColor = theme.colors.toolbar; + } + else { + toolbarColor = Color.mixCSSColors(themeBaseColor, theme.colors.toolbar); + } + extraColors.push(`--browser-toolbar: ${theme.colors.toolbar}`); + if (Color.isParsable(theme.colors.toolbar) && + Color.isParsable(theme.colors.toolbar_text)) { + const halfTransparentTextColor = Color.mixCSSColors(theme.colors.toolbar_text, theme.colors.toolbar_text, 0.5); + extraColors.push(`--browser-toolbar_text-darker: ${Color.mixCSSColors(theme.colors.toolbar, halfTransparentTextColor)}`); + } + } + else if (hasImage) { + extraColors.push('--browser-bg-for-header-image: rgba(255, 255, 255, 0.25);'); + } + if (theme.colors.tab_line) + extraColors.push(`--browser-tab-highlighter: ${theme.colors.tab_line}`); + if (theme.colors.tab_loading) + extraColors.push(`--browser-loading-indicator: ${theme.colors.tab_loading}`); + if (theme.colors.tab_selected) + extraColors.push(`--browser-selected-tab-bg: ${theme.colors.tab_selected}`); + extraColors.push(generateThemeRules(theme)); + return ` + :root { + --browser-background: ${themeFrameColor}; + --browser-bg-base: ${toolbarColor}; + --browser-bg-less-lighter: ${Color.mixCSSColors(toolbarColor, 'rgba(255, 255, 255, 0.05)', bgAlpha)}; + --browser-bg-lighter: ${Color.mixCSSColors(toolbarColor, 'rgba(255, 255, 255, 0.1)', bgAlpha)}; + --browser-bg-more-lighter: ${Color.mixCSSColors(toolbarColor, 'rgba(255, 255, 255, 0.25)', bgAlpha)}; + --browser-bg-lightest: ${Color.mixCSSColors(toolbarColor, 'rgba(255, 255, 255, 0.4)', bgAlpha)}; + --browser-bg-less-darker: ${Color.mixCSSColors(toolbarColor, 'rgba(0, 0, 0, 0.1)', bgAlpha)}; + --browser-bg-darker: ${Color.mixCSSColors(toolbarColor, 'rgba(0, 0, 0, 0.25)', bgAlpha)}; + --browser-bg-more-darker: ${Color.mixCSSColors(toolbarColor, 'rgba(0, 0, 0, 0.5)', bgAlpha)}; + --browser-fg: ${inactiveTextColor}; + --browser-fg-active: ${activeTextColor}; + --browser-border: ${Color.overrideCSSAlpha(inactiveTextColor, 0.4)}; + ${extraColors.join(';\n')} + } + `; +} diff --git a/waterfox/browser/components/sidebar/common/cache-storage.js b/waterfox/browser/components/sidebar/common/cache-storage.js new file mode 100644 index 000000000000..7ea7bb9f8c3f --- /dev/null +++ b/waterfox/browser/components/sidebar/common/cache-storage.js @@ -0,0 +1,318 @@ +/* +# 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'; + +import * as UniqueId from '/common/unique-id.js'; +import { + asyncRunWithTimeout, +} from '/common/common.js'; + +const DB_NAME = 'PermanentStorage'; +const DB_VERSION = 3; +const EXPIRATION_TIME_IN_MSEC = 7 * 24 * 60 * 60 * 1000; // 7 days +const TIMEOUT_IN_MSEC = 1000 * 5; // 5 sec + +export const BACKGROUND = 'backgroundCaches'; +const SIDEBAR = 'sidebarCaches'; // obsolete, but left here to delete old storage + +let mOpenedDB; + +async function openDB() { + if (mOpenedDB) + return mOpenedDB; + return new Promise((resolve, _reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + // This can fail if this is in a private window. + // See: https://github.com/piroor/treestyletab/issues/3387 + //reject(new Error('Failed to open database')); + resolve(null); + }; + + request.onsuccess = () => { + const db = request.result; + mOpenedDB = db; + resolve(db); + }; + + request.onupgradeneeded = (event) => { + const db = event.target.result; + const objectStores = db.objectStoreNames; + + const needToUpgrade = event.oldVersion < DB_VERSION; + if (needToUpgrade) { + if (objectStores.contains(BACKGROUND)) + db.deleteObjectStore(BACKGROUND); + if (objectStores.contains(SIDEBAR)) + db.deleteObjectStore(SIDEBAR); + } + + if (needToUpgrade || + !objectStores.contains(BACKGROUND)) { + const backgroundCachesStore = db.createObjectStore(BACKGROUND, { keyPath: 'key', unique: true }); + backgroundCachesStore.createIndex('windowId', 'windowId', { unique: false }); + backgroundCachesStore.createIndex('timestamp', 'timestamp'); + } + }; + }); +} + +export async function setValue({ windowId, key, value } = {}) { + const [db, windowUniqueId] = await Promise.all([ + openDB(), + UniqueId.ensureWindowId(windowId), + ]); + if (!db) + return; + + reserveToExpireOldEntries(); + + const store = BACKGROUND; + + const cacheKey = `${windowUniqueId}-${key}`; + asyncRunWithTimeout({ + task: () => new Promise((resolve, reject) => { + const timestamp = Date.now(); + try { + const transaction = db.transaction([store], 'readwrite'); + const cacheStore = transaction.objectStore(store); + const cacheRequest = cacheStore.put({ + key: cacheKey, + windowId: windowUniqueId, + value, + timestamp, + }); + + transaction.oncomplete = () => { + //db.close(); + windowId = undefined; + key = undefined; + value = undefined; + resolve(); + }; + + cacheRequest.onerror = event => { + console.error(`Failed to store cache ${cacheKey} in the store ${store}`, event); + reject(event); + }; + } + catch(error) { + console.error(`Failed to store cache ${cacheKey} in the store ${store}`, error); + reject(error); + } + }), + timeout: TIMEOUT_IN_MSEC, + onTimedOut() { + throw new Error(`CacheStorage.setValue for ${windowId}/key timed out`); + }, + }); +} + +export async function deleteValue({ windowId, key } = {}) { + const [db, windowUniqueId] = await Promise.all([ + openDB(), + UniqueId.ensureWindowId(windowId), + ]); + if (!db) + return; + + reserveToExpireOldEntries(); + + const store = BACKGROUND; + + const cacheKey = `${windowUniqueId}-${key}`; + asyncRunWithTimeout({ + task: () => new Promise((resolve, reject) => { + try { + const transaction = db.transaction([store], 'readwrite'); + const cacheStore = transaction.objectStore(store); + const cacheRequest = cacheStore.delete(cacheKey); + + transaction.oncomplete = () => { + //db.close(); + windowId = undefined; + key = undefined; + resolve(); + }; + + cacheRequest.onerror = event => { + console.error(`Failed to delete cache ${cacheKey} in the store ${store}`, event); + reject(event); + }; + } + catch(error) { + console.error(`Failed to delete cache ${cacheKey} in the store ${store}`, error); + reject(error); + } + }), + timeout: TIMEOUT_IN_MSEC, + onTimedOut() { + throw new Error(`CacheStorage.deleteValue for {windowId}/key timed out`); + }, + }); +} + +export async function getValue({ windowId, key } = {}) { + return asyncRunWithTimeout({ + task: () => getValueInternal({ windowId, key }), + timeout: TIMEOUT_IN_MSEC, + onTimedOut() { + throw new Error(`CacheStorage.getValue for {windowId}/${key} timed out`); + }, + }); +} +async function getValueInternal({ windowId, key } = {}) { + return new Promise(async (resolve, _reject) => { + const [db, windowUniqueId] = await Promise.all([ + openDB(), + UniqueId.ensureWindowId(windowId), + ]); + if (!db) { + resolve(null); + return; + } + + const store = BACKGROUND; + + const cacheKey = `${windowUniqueId}-${key}`; + const timestamp = Date.now(); + try { + const transaction = db.transaction([store], 'readwrite'); + const cacheStore = transaction.objectStore(store); + + const cacheRequest = cacheStore.get(cacheKey); + + cacheRequest.onsuccess = () => { + const cache = cacheRequest.result; + if (!cache) { + resolve(null); + return; + } + // IndexedDB does not support partial update, so + // we need to put all properties not only timestamp. + cacheStore.put({ + key: cacheKey, + windowId: windowUniqueId, + value: cache.value, + timestamp, + }); + resolve(cache.value); + cache.key = undefined; + cache.windowId = undefined; + cache.value = undefined; + }; + + cacheRequest.onerror = event => { + console.error('Failed to get from cache:', event); + resolve(null); + }; + + transaction.oncomplete = () => { + //db.close(); + windowId = undefined; + key = undefined; + }; + } + catch(error) { + console.error('Failed to get from cache:', error); + resolve(null); + } + }); +} + +export async function clearForWindow(windowId) { + return asyncRunWithTimeout({ + task: () => clearForWindowInternal(windowId), + timeout: TIMEOUT_IN_MSEC, + onTimedOut() { + throw new Error(`CacheStorage.clearForWindow for ${windowId} timed out`); + }, + }); +} +async function clearForWindowInternal(windowId) { + reserveToExpireOldEntries(); + return new Promise(async (resolve, reject) => { + const [db, windowUniqueId] = await Promise.all([ + openDB(), + UniqueId.ensureWindowId(windowId), + ]); + if (!db) { + resolve(null); + return; + } + + try { + const transaction = db.transaction([BACKGROUND], 'readwrite'); + const backgroundCacheStore = transaction.objectStore(BACKGROUND); + const backgroundCacheIndex = backgroundCacheStore.index('windowId'); + const backgroundCacheRequest = backgroundCacheIndex.openCursor(IDBKeyRange.only(windowUniqueId)); + backgroundCacheRequest.onsuccess = (event) => { + const cursor = event.target.result; + if (!cursor) + return; + const key = cursor.primaryKey; + cursor.continue(); + backgroundCacheStore.delete(key); + }; + + transaction.oncomplete = () => { + //db.close(); + resolve(); + }; + } + catch(error) { + console.error('Failed to clear caches:', error); + reject(error); + } + }); +} + +async function reserveToExpireOldEntries() { + if (reserveToExpireOldEntries.reservedExpiration) + clearTimeout(reserveToExpireOldEntries.reservedExpiration); + reserveToExpireOldEntries.reservedExpiration = setTimeout(() => { + reserveToExpireOldEntries.reservedExpiration = null; + expireOldEntries(); + }, 500); +} + +async function expireOldEntries() { + return new Promise(async (resolve, reject) => { + const db = await openDB(); + if (!db) { + resolve(); + return; + } + + try { + const transaction = db.transaction([BACKGROUND], 'readwrite'); + const backgroundCacheStore = transaction.objectStore(BACKGROUND); + const backgroundCacheIndex = backgroundCacheStore.index('timestamp'); + + const expirationTimestamp = Date.now() - EXPIRATION_TIME_IN_MSEC; + + const backgroundCacheRequest = backgroundCacheIndex.openCursor(IDBKeyRange.upperBound(expirationTimestamp)); + backgroundCacheRequest.onsuccess = (event) => { + const cursor = event.target.result; + if (!cursor) + return; + const key = cursor.primaryKey; + cursor.continue(); + backgroundCacheStore.delete(key); + }; + + transaction.oncomplete = () => { + //db.close(); + resolve(); + }; + } + catch(error) { + console.error('Failed to expire old entries:', error); + reject(error); + } + }); +} diff --git a/waterfox/browser/components/sidebar/common/color.js b/waterfox/browser/components/sidebar/common/color.js new file mode 100644 index 000000000000..b440fa0acb0a --- /dev/null +++ b/waterfox/browser/components/sidebar/common/color.js @@ -0,0 +1,199 @@ +/* +# 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'; + +export function mixCSSColors(base, over, alpha = 1) { + base = parseCSSColor(base); + over = parseCSSColor(over); + const mixed = mixColors(base, over); + return `rgba(${mixed.red}, ${mixed.green}, ${mixed.blue}, ${alpha})`; +} + +export function overrideCSSAlpha(color, alpha = 1) { + const parsed = parseCSSColor(color); + let base = color; + let baseAlpha = parsed.alpha; + if (baseAlpha > 0) { + while (baseAlpha < 1) { + base = mixCSSColors(base, base); + baseAlpha *= 2; + } + } + return mixCSSColors(base, base, alpha); +} + +function normalizeColorElement(value) { + return Math.max(0, Math.min(255, value.endsWith('%') ? (parseFloat(value) / 100 * 255) : parseFloat(value))); +} + +function normalizeAlpha(value) { + if (!value) + return 1; + return Math.max(0, Math.min(1, value.endsWith('%') ? (parseFloat(value) / 100) : parseFloat(value))); +} + +function normalizeAngle(value, unit) { + value = parseFloat(value); + switch ((unit || '').toLowerCase()) { + case 'rad': + value = value * (180 / Math.PI); + break; + + case 'grad': + value = value * 0.9; + break; + + case 'turn': + value = value * 360; + break; + + default: // deg + break; + } + value = value % 360; + if (value < 0) + value += 360; + return value; +} + +const HEX = '[0-9a-f]'; +const DOUBLE_HEX = `${HEX}{2}`; +const FLOAT = '(?:\\.?[0-9]+|[0-9]+(?:\\.[0-9]+)?)'; +const FLOAT_OR_PERCENTAGE = `${FLOAT}%?`; + +const HEX_RRGGBBAA_MATCHER = new RegExp(`^#?(${DOUBLE_HEX})(${DOUBLE_HEX})(${DOUBLE_HEX})(${DOUBLE_HEX})?$`, 'i'); +const HEX_RGBA_MATCHER = new RegExp(`^#?(${HEX})(${HEX})(${HEX})(${HEX})?$`, 'i'); +const RGBA_MATCHER = new RegExp(`^rgba?\\(\\s*(${FLOAT_OR_PERCENTAGE})\\s*,?\\s*(${FLOAT_OR_PERCENTAGE})\\s*,?\\s*(${FLOAT_OR_PERCENTAGE})(?:\\s*[,/]?\\s*(${FLOAT_OR_PERCENTAGE})\\s*)?\\)$`, 'i'); +const HSLA_MATCHER = new RegExp(`^hsla?\\(\\s*(${FLOAT})(deg|rad|grad|turn)?\\s*,?\\s*(${FLOAT_OR_PERCENTAGE})\\s*,?\\s*(${FLOAT_OR_PERCENTAGE})(?:\\s*[,/]?\\s*(${FLOAT_OR_PERCENTAGE})\\s*)?\\)$`, 'i'); + +export function isParsable(color) { + if (!color) + return false; + const stringifiedColor = String(color); + return HEX_RRGGBBAA_MATCHER.test(stringifiedColor) || + HEX_RGBA_MATCHER.test(stringifiedColor) || + RGBA_MATCHER.test(stringifiedColor) || + HSLA_MATCHER.test(stringifiedColor); +} + +export function parseCSSColor(color, baseColor) { + if (typeof color!= 'string') + return color; + + let red, green, blue, alpha; + + let parts = color.match(HEX_RRGGBBAA_MATCHER); + if (parts) { + red = parseInt(parts[1], 16); + green = parseInt(parts[2], 16); + blue = parseInt(parts[3], 16); + alpha = parts[4] ? parseInt(parts[4], 16) / 255 : 1 ; + } + if (!parts) { + // #RGB, #RGBA + parts = color.match(HEX_RGBA_MATCHER); + if (parts) { + red = Math.min(255, Math.round(255 * (parseInt(parts[1], 16) / 16))); + green = Math.min(255, Math.round(255 * (parseInt(parts[2], 16) / 16))); + blue = Math.min(255, Math.round(255 * (parseInt(parts[3], 16) / 16))); + alpha = parts[4] ? parseInt(parts[4], 16) / 16 : 1 ; + } + } + if (!parts) { + // rgb(), rgba() + parts = color.match(RGBA_MATCHER); + if (parts) { + red = normalizeColorElement(parts[1]); + green = normalizeColorElement(parts[2]); + blue = normalizeColorElement(parts[3]); + alpha = normalizeAlpha(parts[4]); + } + } + if (!parts) { + // hsl(), hsla() + parts = color.match(HSLA_MATCHER); + if (parts) { + const hue = normalizeAngle(parts[1], parts[2]); + const saturation = parseFloat(parts[3]); + const lightness = parseFloat(parts[4]); + let min, max; + if (lightness < 50) { + max = 2.55 * (lightness + (lightness * (saturation / 100))); + min = 2.55 * (lightness - (lightness * (saturation / 100))); + } + else { + max = 2.55 * (lightness + ((100 - lightness) * (saturation / 100))); + min = 2.55 * (lightness - ((100 - lightness) * (saturation / 100))); + } + if (hue < 60) { + red = max; + green = (hue / 60) * (max - min) + min; + blue = min; + } + else if (hue < 120) { + red = ((120 - hue) / 60) * (max - min) + min; + green = max; + blue = min; + } + else if (hue < 180) { + red = min; + green = max; + blue = ((hue - 120) / 60) * (max - min) + min; + } + else if (hue < 240) { + red = min; + green = ((240 - hue) / 60) * (max - min) + min; + blue = max; + } + else if (hue < 300) { + red = ((hue - 240) / 60) * (max - min) + min; + green = min; + blue = max; + } + else { + red = max; + green = min; + blue = ((360 - hue) / 60) * (max - min) + min; + } + alpha = normalizeAlpha(parts[5]); + } + } + if (!parts) { + switch(color.toLowerCase()) { + case 'transparent': + red = 0; + green = 0; + blue = 0; + alpha = 0; + break; + + default: + return color; + } + } + + const parsed = { red, green, blue, alpha }; + + if (alpha < 1 && baseColor) + return mixColors(parseCSSColor(baseColor), parsed); + + return parsed; +} + +function mixColors(base, over) { + const alpha = over.alpha; + const red = Math.min(255, Math.round((base.red * (1 - alpha)) + (over.red * alpha))); + const green = Math.min(255, Math.round((base.green * (1 - alpha)) + (over.green * alpha))); + const blue = Math.min(255, Math.round((base.blue * (1 - alpha)) + (over.blue * alpha))); + return { red, green, blue, alpha: 1 }; +} + +export function isBrightColor(color) { + color = parseCSSColor(color); + // https://searchfox.org/mozilla-central/rev/532e4b94b9e807d157ba8e55034aef05c1196dc9/browser/base/content/browser.js#8200 + const luminance = (color.red * 0.2125) + (color.green * 0.7154) + (color.blue * 0.0721); + return luminance > 110; +} diff --git a/waterfox/browser/components/sidebar/common/common.js b/waterfox/browser/components/sidebar/common/common.js new file mode 100644 index 000000000000..86229801f69b --- /dev/null +++ b/waterfox/browser/components/sidebar/common/common.js @@ -0,0 +1,1289 @@ +/* +# 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'; + +import Configs from '/extlib/Configs.js'; +import EventListenerManager from '/extlib/EventListenerManager.js'; + +import * as Constants from './constants.js'; + +const WATERFOX_SPECIFIC_VALUES = { + sidebarPosition: Constants.kTABBAR_POSITION_LEFT, + suppressGapFromShownOrHiddenToolbarOnlyOnMouseOperation: false, + suppressGapFromShownOrHiddenToolbarOnNewTab: false, + showTreeCommandsInTabsContextMenuGlobally: false, + stickyActiveTab: false, + stickySoundPlayingTab: false, + stickySharingTab: false, + style: 'proton', + exposeUnblockAutoplayFeatures: true, + + // because full options page is already behind the "Advanced" options + showExpertOptions: true, + + // don't attach tabs as children of the active tab by default if possible + autoAttachOnOpenedWithOwner: Constants.kNEWTAB_DO_NOTHING, + insertNewTabFromPinnedTabAt: Constants.kINSERT_NO_CONTROL, + insertNewTabFromFirefoxViewAt: Constants.kINSERT_NO_CONTROL, + autoGroupNewTabsFromPinned: false, + autoAttachOnNewTabButtonMiddleClick: Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING, + autoAttachSameSiteOrphan: Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING, + + // suppress notification at the initial startup + notifiedFeaturesVersion: 99999, + syncAvailableNotified: true, + + // by our custom module, drag and drop of the tree is natively supported. + showTabDragBehaviorNotification: false, + enableWorkaroundForBug1548949: false, + + // covered by our custom module + enableWorkaroundForBug1875100: false, +}; +export const WATERFOX_AUTOMATED_TEST_DEFAULT_VALUES = { + maxTreeLevel: -1, + autoAttach: true, + syncParentTabAndOpenerTab: true, + autoAttachOnOpenedWithOwner: Constants.kNEWTAB_OPEN_AS_CHILD_END, + autoGroupNewTabsFromPinned: true, + autoAttachOnNewTabButtonMiddleClick: Constants.kNEWTAB_OPEN_AS_CHILD_END, + autoAttachSameSiteOrphan: Constants.kNEWTAB_OPEN_AS_CHILD_END, +}; + +export const DEVICE_SPECIFIC_CONFIG_KEYS = mapAndFilter(` + blockStartupOperations + chunkedSyncDataLocal0 + chunkedSyncDataLocal1 + chunkedSyncDataLocal2 + chunkedSyncDataLocal3 + chunkedSyncDataLocal4 + chunkedSyncDataLocal5 + chunkedSyncDataLocal6 + chunkedSyncDataLocal7 + fixDragEndCoordinates + lastConfirmedToCloseTabs + lastDragOverSidebarOwnerWindowId + lastDraggedTabs + loggingConnectionMessages + loggingQueries + migratedBookmarkUrls + requestingPermissions + requestingPermissionsNatively + syncAvailableNotified + syncDeviceInfo + syncDevicesLocalCache + syncEnabled + syncLastMessageTimestamp + syncOtherDevicesDetected +`.trim().split('\n'), key => { + key = key.trim(); + return key && key.indexOf('//') != 0 && key; +}); + +const localKeys = DEVICE_SPECIFIC_CONFIG_KEYS.concat(mapAndFilter(` + APIEnabled + accelKey + baseIndent + cachedExternalAddons + colorScheme + debug + enableLinuxBehaviors + enableMacOSBehaviors + enableWindowsBehaviors + faviconizedTabScale + grantedExternalAddonPermissions + grantedRemovingTabIds + incognitoAllowedExternalAddons + logFor + logTimestamp + maximumDelayForBug1561879 + minimumIntervalToProcessDragoverEvent + minIndent + notifiedFeaturesVersion + optionsExpandedGroups + optionsExpandedSections + outOfScreenTabsRenderingPages + rtl, + sidebarPosition + sidebarVirtuallyClosedWindows + sidebarVirtuallyOpenedWindows + sidebarWidthInWindow + startDragTimeout + style + subMenuCloseDelay + subMenuOpenDelay + testKey + userStyleRulesFieldHeight + userStyleRulesFieldTheme + runTestsParameters +`.trim().split('\n'), key => { + key = key.trim(); + return key && key.indexOf('//') != 0 && key; +})); + +export const obsoleteConfigs = new Set(mapAndFilter(` + sidebarScrollbarPosition // migrated to user stylesheet + scrollbarMode // migrated to user stylesheet + suppressGapFromShownOrHiddenToolbar // migrated to suppressGapFromShownOrHiddenToolbarOnFullScreen/NewTab + fakeContextMenu // migrated to emulateDefaultContextMenu + context_closeTabOptions_closeTree // migrated to context_topLevel_closeTree + context_closeTabOptions_closeDescendants // migrated to context_topLevel_closeDescendants + context_closeTabOptions_closeOthers // migrated to context_topLevel_closeOthers + collapseExpandSubtreeByDblClick // migrated to treeDoubleClickBehavior + autoExpandOnCollapsedChildActive // migrate to unfocusableCollapsedTab + inheritContextualIdentityToNewChildTab // migrated to inheritContextualIdentityToChildTabMode + inheritContextualIdentityToSameSiteOrphan // migrated to inheritContextualIdentityToSameSiteOrphanMode + inheritContextualIdentityToTabsFromExternal // migrated to inheritContextualIdentityToTabsFromExternalMode + promoteFirstChildForClosedRoot // migrated to Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY of closeParentBehavior + parentTabBehaviorForChanges // migrated to parentTabOperationBehaviorMode + closeParentBehaviorMode // migrated to parentTabOperationBehaviorMode + closeParentBehavior // migrated to closeParentBehavior_insideSidebar_expanded + closeParentBehavior_outsideSidebar// migrated to closeParentBehavior_outsideSidebar_expanded + closeParentBehavior_noSidebar // migrated to closeParentBehavior_noSidebar_expanded + treatTreeAsExpandedOnClosedWithNoSidebar // migrated to treatTreeAsExpandedOnClosed_noSidebar + treatTreeAsExpandedOnClosed_outsideSidebar // migrated to closeParentBehavior_noSidebar_expanded and closeParentBehavior_noSidebar_expanded + treatTreeAsExpandedOnClosed_noSidebar // migrated to closeParentBehavior_noSidebar_collapsed and moveParentBehavior_noSidebar_expanded + moveFocusInTreeForClosedActiveTab // migrated to "successorTabControlLevel" + startDragTimeout // migrated to longPressDuration + simulateCloseTabByDblclick // migrated to "treeDoubleClickBehavior=kTREE_DOUBLE_CLICK_BEHAVIOR_CLOSE" + moveDroppedTabToNewWindowForUnhandledDragEvent // see also: https://github.com/piroor/treestyletab/issues/1646 , migrated to tabDragBehavior + openAllBookmarksWithGroupAlways // migrated to suppressGroupTabForStructuredTabsFromBookmarks + // migrated to chunkedUserStyleRules0-5 + userStyleRules0 + userStyleRules1 + userStyleRules2 + userStyleRules3 + userStyleRules4 + userStyleRules5 + userStyleRules6 + userStyleRules7 + autoGroupNewTabsTimeout // migrated to tabBunchesDetectionTimeout + autoGroupNewTabsDelayOnNewWindow // migrated to tabBunchesDetectionDelayOnNewWindow + autoHiddenScrollbarPlaceholderSize // migrated to shiftTabsForScrollbarDistance +`.trim().split('\n'), key => { + key = key.replace(/\/\/.*/, '').trim(); + if (!key) + return undefined; + return key && key.indexOf('//') != 0 && key; +})); + + +const RTL_LANGUAGES = new Set([ + 'ar', + 'he', + 'fa', + 'ur', + 'ps', + 'sd', + 'ckb', + 'prs', + 'rhg', +]); + +export function isRTL() { + const lang = ( + navigator.language || + navigator.userLanguage || + //(new Intl.DateTimeFormat()).resolvedOptions().locale || + '' + ).split('-')[0]; + return RTL_LANGUAGES.has(lang); +} + + +export const configs = new Configs({ + optionsExpandedSections: ['section-appearance'], + optionsExpandedGroups: [], + + // appearance + sidebarPosition: Constants.kTABBAR_POSITION_AUTO, + sidebarPositionRighsideNotificationShown: false, + sidebarPositionOptionNotificationTimeout: 20 * 1000, + rtl: isRTL(), + + style: /^Mac/i.test(navigator.platform) ? 'sidebar' : 'proton', + colorScheme: /^Linux/i.test(navigator.platform) ? 'system-color' : 'photon' , + iconColor: 'auto', + indentLine: 'auto', + + shiftTabsForScrollbarDistance: '0.5em', + shiftTabsForScrollbarOnlyOnHover: false, + + unrepeatableBGImageAspectRatio: 4, + + faviconizePinnedTabs: true, + maxFaviconizedPinnedTabsInOneRow: 0, // auto + faviconizedTabScale: 1.75, + maxPinnedTabsRowsAreaPercentage: 50, + fadeOutPendingTabs: false, // simulates browser.tabs.fadeOutUnloadedTabs + fadeOutDiscardedTabs: true, // simulates browser.tabs.fadeOutExplicitlyUnloadedTabs + + counterRole: Constants.kCOUNTER_ROLE_CONTAINED_TABS, + + baseIndent: 12, + minIndent: Constants.kDEFAULT_MIN_INDENT, + maxTreeLevel: -1, + indentAutoShrink: true, + indentAutoShrinkOnlyForVisible: true, + labelOverflowStyle: 'fade', + + showContextualIdentitiesSelector: false, + showNewTabActionSelector: true, + longPressOnNewTabButton: Constants.kCONTEXTUAL_IDENTITY_SELECTOR, + zoomable: false, + + inContentUIOffsetTop: 0, // See also https://github.com/piroor/treestyletab/issues/3698 + tabPreviewTooltip: false, + tabPreviewTooltipRenderIn: Constants.kIN_CONTENT_PANEL_RENDER_IN_ANYWHERE, + tabPreviewTooltipInSidebar: null, // migrated to tabPreviewTooltipMode + tabPreviewTooltipDelayMsec: 500, // same as "ui.tooltip.delay_ms" + tabPreviewTooltipOffsetTop: null, // migrated to inContentUIOffsetTop + showOverflowTitleByTooltip: true, + showCollapsedDescendantsByTooltip: true, + showCollapsedDescendantsByLegacyTooltipOnSidebar: true, + tabGroupMenuPanelRenderIn: Constants.kIN_CONTENT_PANEL_RENDER_IN_ANYWHERE, + + showDialogInSidebar: false, + + outOfScreenTabsRenderingPages: 1, + renderHiddenTabs: false, + + suppressGapFromShownOrHiddenToolbarOnlyOnMouseOperation: true, + suppressGapFromShownOrHiddenToolbarOnFullScreen: false, + suppressGapFromShownOrHiddenToolbarOnNewTab: true, + suppressGapFromShownOrHiddenToolbarInterval: 50, + suppressGapFromShownOrHiddenToolbarTimeout: 500, + cancelGapSuppresserHoverDelay: 1000, // msec + + + // context menu + emulateDefaultContextMenu: true, + showTreeCommandsInTabsContextMenuGlobally: true, + + context_reloadTree: true, + context_reloadDescendants: false, + context_unblockAutoplayTree: true, + context_unblockAutoplayDescendants: false, + context_toggleMuteTree: true, + context_toggleMuteDescendants: false, + context_closeTree: true, + context_closeDescendants: false, + context_closeOthers: false, + context_toggleSticky: false, + context_collapseTree: false, + context_collapseTreeRecursively: true, + context_collapseAll: true, + context_expandTree: false, + context_expandTreeRecursively: true, + context_expandAll: true, + context_bookmarkTree: true, + context_sendTreeToDevice: false, + + context_topLevel_reloadTree: false, + context_topLevel_reloadDescendants: false, + context_topLevel_unblockAutoplayTree: false, + context_topLevel_unblockAutoplayDescendants: false, + context_topLevel_toggleMuteTree: false, + context_topLevel_toggleMuteDescendants: false, + context_topLevel_closeTree: false, + context_topLevel_closeDescendants: false, + context_topLevel_closeOthers: false, + context_topLevel_toggleSticky: true, + context_topLevel_collapseTree: false, + context_topLevel_collapseTreeRecursively: false, + context_topLevel_collapseAll: false, + context_topLevel_expandTree: false, + context_topLevel_expandTreeRecursively: false, + context_topLevel_expandAll: false, + context_topLevel_bookmarkTree: false, + context_topLevel_sendTreeToDevice: true, + + context_collapsed: false, + context_pinnedTab: false, + context_unpinnedTab: false, + + context_openAllBookmarksWithStructure: true, + context_openAllBookmarksWithStructureRecursively: false, + + openAllBookmarksWithStructureDiscarded: true, + suppressGroupTabForStructuredTabsFromBookmarks: true, + + + // tree behavior + shouldDetectClickOnIndentSpaces: true, + + autoCollapseExpandSubtreeOnAttach: true, + autoCollapseExpandSubtreeOnSelect: true, + autoCollapseExpandSubtreeOnSelectExceptActiveTabRemove: true, + + treeDoubleClickBehavior: Constants.kTREE_DOUBLE_CLICK_BEHAVIOR_NONE, + + autoExpandIntelligently: true, + unfocusableCollapsedTab: true, + autoExpandOnTabSwitchingShortcuts: true, + autoExpandOnTabSwitchingShortcutsDelay: 800, + autoExpandOnLongHover: true, + autoExpandOnLongHoverDelay: 500, + autoExpandOnLongHoverRestoreIniitalState: true, + + autoCreateFolderForBookmarksFromTree: true, + + accelKey: '', + + skipCollapsedTabsForTabSwitchingShortcuts: false, + + syncParentTabAndOpenerTab: true, + + dropLinksOnTabBehavior: Constants.kDROPLINK_ASK, + + tabDragBehavior: Constants.kDRAG_BEHAVIOR_MOVE | Constants.kDRAG_BEHAVIOR_TEAR_OFF | Constants.kDRAG_BEHAVIOR_ENTIRE_TREE, + tabDragBehaviorShift: Constants.kDRAG_BEHAVIOR_MOVE | Constants.kDRAG_BEHAVIOR_ENTIRE_TREE | Constants.kDRAG_BEHAVIOR_ALLOW_BOOKMARK, + showTabDragBehaviorNotification: true, + guessDraggedNativeTabs: true, + ignoreTabDropNearSidebarArea: true, + moveSoloTabOnDropParentToDescendant: true, + + fixupTreeOnTabVisibilityChanged: false, + fixupOrderOfTabsFromOtherDevice: true, + + scrollToExpandedTree: true, + syncActiveStateToBundledTabs: true, + + spreadMutedStateOnlyToSoundPlayingTabs: true, + + + // tab bunches + tabBunchesDetectionTimeout: 100, + tabBunchesDetectionDelayOnNewWindow: 500, + autoGroupNewTabsFromBookmarks: true, + restoreTreeForTabsFromBookmarks: true, + tabsFromSameFolderMinThresholdPercentage: 50, + autoGroupNewTabsFromOthers: false, + autoGroupNewTabsFromPinned: true, + autoGroupNewTabsFromFirefoxView: false, + groupTabTemporaryStateForNewTabsFromBookmarks: Constants.kGROUP_TAB_TEMPORARY_STATE_PASSIVE, + groupTabTemporaryStateForNewTabsFromOthers: Constants.kGROUP_TAB_TEMPORARY_STATE_PASSIVE, + groupTabTemporaryStateForChildrenOfPinned: Constants.kGROUP_TAB_TEMPORARY_STATE_PASSIVE, + groupTabTemporaryStateForChildrenOfFirefoxView: Constants.kGROUP_TAB_TEMPORARY_STATE_PASSIVE, + groupTabTemporaryStateForOrphanedTabs: Constants.kGROUP_TAB_TEMPORARY_STATE_AGGRESSIVE, + groupTabTemporaryStateForAPI: Constants.kGROUP_TAB_TEMPORARY_STATE_NOTHING, + renderTreeInGroupTabs: true, + warnOnAutoGroupNewTabs: true, + warnOnAutoGroupNewTabsWithListing: true, + warnOnAutoGroupNewTabsWithListingMaxRows: 5, + showAutoGroupOptionHint: true, + showAutoGroupOptionHintWithOpener: true, + + + // behavior around newly opened tabs + insertNewChildAt: Constants.kINSERT_END, // basically this option affects only very edge cases not controlled with "autoAttach*" options. + insertNewTabFromPinnedTabAt: Constants.kINSERT_NEXT_TO_LAST_RELATED_TAB, + insertNewTabFromFirefoxViewAt: Constants.kINSERT_NEXT_TO_LAST_RELATED_TAB, + insertDroppedTabsAt: Constants.kINSERT_END, + + scrollToNewTabMode: Constants.kSCROLL_TO_NEW_TAB_IF_POSSIBLE, + scrollLines: 3, + + autoAttach: true, + autoAttachOnOpenedWithOwner: Constants.kNEWTAB_OPEN_AS_CHILD_END, + autoAttachOnNewTabCommand: Constants.kNEWTAB_DO_NOTHING, + autoAttachOnContextNewTabCommand: Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING_WITH_INHERITED_CONTAINER, + autoAttachOnNewTabButtonMiddleClick: Constants.kNEWTAB_OPEN_AS_CHILD_END, + middleClickPasteURLOnNewTabButton: /^Linux/i.test(navigator.platform), // simulates "browser.tabs.searchclipboardfor.middleclick" + autoAttachOnNewTabButtonAccelClick: Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING_WITH_INHERITED_CONTAINER, + autoAttachOnDuplicated: Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING, + autoAttachSameSiteOrphan: Constants.kNEWTAB_OPEN_AS_CHILD_END, + autoAttachOnOpenedFromExternal: Constants.kNEWTAB_DO_NOTHING, + autoAttachOnAnyOtherTrigger: Constants.kNEWTAB_DO_NOTHING, + guessNewOrphanTabAsOpenedByNewTabCommand: true, + guessNewOrphanTabAsOpenedByNewTabCommandTitle: browser.i18n.getMessage('guessNewOrphanTabAsOpenedByNewTabCommandTitle'), + guessNewOrphanTabAsOpenedByNewTabCommandUrl: 'about:newtab|about:privatebrowsing', + inheritContextualIdentityToChildTabMode: Constants.kCONTEXTUAL_IDENTITY_DEFAULT, + inheritContextualIdentityToSameSiteOrphanMode: Constants.kCONTEXTUAL_IDENTITY_FROM_LAST_ACTIVE, + inheritContextualIdentityToTabsFromExternalMode: Constants.kCONTEXTUAL_IDENTITY_DEFAULT, + inheritContextualIdentityToTabsFromAnyOtherTriggerMode: Constants.kCONTEXTUAL_IDENTITY_DEFAULT, + inheritContextualIdentityToUnopenableURLTabs: false, + + + // behavior around closed tab + parentTabOperationBehaviorMode: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_MODE_PARALLEL, + //closeParentBehavior_insideSidebar_collapsed: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE, // permanently consistent + closeParentBehavior_insideSidebar_expanded: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + closeParentBehavior_outsideSidebar_collapsed: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + closeParentBehavior_outsideSidebar_expanded: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + closeParentBehavior_noSidebar_collapsed: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + closeParentBehavior_noSidebar_expanded: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + //moveParentBehavior_insideSidebar_collapsed: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE, // permanently consistent + //moveParentBehavior_insideSidebar_expanded: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE, // permanently consistent + moveParentBehavior_outsideSidebar_collapsed: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE, + moveParentBehavior_outsideSidebar_expanded: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + moveParentBehavior_noSidebar_collapsed: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + moveParentBehavior_noSidebar_expanded: Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD, + closeParentBehavior_replaceWithGroup_thresholdToPrevent: 1, // negative value means "never prevent" + moveTabsToBottomWhenDetachedFromClosedParent: false, + promoteAllChildrenWhenClosedParentIsLastChild: true, + successorTabControlLevel: Constants.kSUCCESSOR_TAB_CONTROL_IN_TREE, + simulateSelectOwnerOnClose: true, + simulateLockTabSizing: true, + deferScrollingToOutOfViewportSuccessor: true, + simulateTabsLoadInBackgroundInverted: false, + tabsLoadInBackgroundDiscarded: false, + warnOnCloseTabs: true, + warnOnCloseTabsNotificationTimeout: 20 * 1000, + warnOnCloseTabsByClosebox: true, + warnOnCloseTabsWithListing: true, + lastConfirmedToCloseTabs: 0, + grantedRemovingTabIds: [], + sidebarVirtuallyOpenedWindows: [], // for automated tests + sidebarVirtuallyClosedWindows: [], // for automated tests + sidebarWidthInWindow: {}, + + + // animation + animation: true, + animationForce: false, + maxAllowedImmediateRefreshCount: 1, + smoothScrollEnabled: true, + smoothScrollDuration: 150, + burstDuration: 375, + indentDuration: 200, + collapseDuration: 150, + outOfViewTabNotifyDuration: 750, + subMenuOpenDelay: 300, + subMenuCloseDelay: 300, + + + // subpanel + lastSelectedSubPanelProviderId: null, + lastSubPanelHeight: 0, + maxSubPanelSizeRatio: 0.66, + + + // misc. + showExpertOptions: false, + exposeUnblockAutoplayFeatures: false, + bookmarkTreeFolderName: browser.i18n.getMessage('bookmarkFolder_label_default', ['%TITLE%', '%YEAR%', '%MONTH%', '%DATE%']), + defaultBookmarkParentId: 'toolbar_____', // 'unfiled_____' for Firefox 83 and olders, + defaultSearchEngine: 'https://www.google.com/search?q=%s', + acceleratedTabOperations: true, + acceleratedTabCreation: false, + enableWorkaroundForBug1548949: true, + enableWorkaroundForBug1763420_reloadMaskImage: true, // workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1763420 + maximumDelayForBug1561879: 500, + workaroundForBug1548949DroppedItems: null, + heartbeatInterval: 5000, + connectionTimeoutDelay: 500, + maximumAcceptableDelayForTabDuplication: 10 * 1000, + maximumDelayUntilTabIsTracked: 10 * 60 * 1000, + delayToBlockUserOperationForTabsRestoration: 1000, + intervalToUpdateProgressForBlockedUserOperation: 50, + delayToShowProgressForBlockedUserOperation: 1000, + acceptableDelayForInternalFocusMoving: 150, + delayForDuplicatedTabDetection: 0, // https://github.com/piroor/treestyletab/issues/2845 + delayToRetrySyncTabsOrder: 100, + notificationTimeout: 10 * 1000, + longPressDuration: 400, + minimumIntervalToProcessDragoverEvent: 50, + delayToApplyHighlightedState: 50, + acceptableFlickerToIgnoreClickOnTabAndTabbar: 10, + autoDiscardTabForUnexpectedFocus: true, + autoDiscardTabForUnexpectedFocusDelay: 500, + avoidDiscardedTabToBeActivatedIfPossible: false, + provressiveHighlightingStep: Number.MAX_SAFE_INTEGER, + progressievHighlightingInterval: 100, + generatedTreeItemElementsPoolLifetimeMsec: 5 * 1000, + nativeTabGroupModificationDetectionTimeoutAfterTabMove: 500, + undoMultipleTabsClose: true, + allowDragNewTabButton: true, + newTabButtonDragGestureModifiers: 'shift', + migratedBookmarkUrls: [], + lastDragOverSidebarOwnerWindowId: null, + notifiedFeaturesVersion: 0, + + useCachedTree: true, + persistCachedTree: true, + + // This should be removed after https://bugzilla.mozilla.org/show_bug.cgi?id=1388193 + // or https://bugzilla.mozilla.org/show_bug.cgi?id=1421329 become fixed. + // Otherwise you need to set "svg.context-properties.content.enabled"="true" via "about:config". + simulateSVGContextFill: true, + + requestingPermissions: null, + requestingPermissionsNatively: null, + lastDraggedTabs: null, + + // https://dxr.mozilla.org/mozilla-central/rev/2535bad09d720e71a982f3f70dd6925f66ab8ec7/browser/base/content/browser.css#137 + newTabAnimationDuration: 100, + + chunkedUserStyleRules0: '', + chunkedUserStyleRules1: '', + chunkedUserStyleRules2: '', + chunkedUserStyleRules3: '', + chunkedUserStyleRules4: '', + chunkedUserStyleRules5: '', + chunkedUserStyleRules6: '', + chunkedUserStyleRules7: '', + // obsolete, migrated to chunkedUserStyleRules0-5 + userStyleRules: ` +/* Show title of unread tabs with red and italic font */ +/* +:root.sidebar tab-item.unread .label-content { + color: red !important; + font-style: italic !important; +} +*/ + +/* Add private browsing indicator per tab */ +/* +:root.sidebar tab-item.private-browsing tab-label:before { + content: "🕶"; +} +*/ +`.trim(), + userStyleRulesFieldHeight: '10em', + userStyleRulesFieldTheme: 'auto', + + syncOtherDevicesDetected: false, + syncAvailableNotified: false, + syncAvailableNotificationTimeout: 20 * 1000, + syncDeviceInfo: null, + syncDevices: {}, + syncDevicesLocalCache: {}, + syncDeviceExpirationDays: 14, + // Must be same to "services.sync.engine.tabs.filteredUrls" + syncUnsendableUrlPattern: '^(about:.*|resource:.*|chrome:.*|wyciwyg:.*|file:.*|blob:.*|moz-extension:.*)$', + syncLastMessageTimestamp: 0, + syncReceivedTabsNotificationTimeout: 20 * 1000, + syncSentTabsNotificationTimeout: 5 * 1000, + chunkedSyncData0: '', + chunkedSyncData1: '', + chunkedSyncData2: '', + chunkedSyncData3: '', + chunkedSyncData4: '', + chunkedSyncData5: '', + chunkedSyncData6: '', + chunkedSyncData7: '', + chunkedSyncDataLocal0: '', + chunkedSyncDataLocal1: '', + chunkedSyncDataLocal2: '', + chunkedSyncDataLocal3: '', + chunkedSyncDataLocal4: '', + chunkedSyncDataLocal5: '', + chunkedSyncDataLocal6: '', + chunkedSyncDataLocal7: '', + + + // Compatibility with other addons + knownExternalAddons: [ + 'multipletab@piro.sakura.ne.jp' + ], + cachedExternalAddons: [], + grantedExternalAddonPermissions: {}, + incognitoAllowedExternalAddons: [], + + // This must be same to the redirect key of Container Bookmarks. + // https://addons.mozilla.org/firefox/addon/container-bookmarks/ + containerRedirectKey: 'container', + + + debug: false, + blockStartupOperations: false, // to collect performance profile around the initialization process + runTestsParameters: '', + syncEnabled: true, + tabGroupsEnabled: true, // corresponding to browser.tabs.groups.enabled + APIEnabled: true, + cacheAPITreeItems: false, + logTimestamp: true, + loggingQueries: false, + logFor: { // git grep configs.logFor | grep -v common.js | cut -d "'" -f 2 | sed -e "s/^/ '/" -e "s/$/': false,/" + 'background/api-tabs-listener': false, + 'background/background-cache': false, + 'background/background': false, + 'background/browser-action-menu': false, + 'background/commands': false, + 'background/context-menu': false, + 'background/handle-misc': false, + 'background/handle-moved-tabs': false, + 'background/handle-new-tabs': false, + 'background/handle-removed-tabs': false, + 'background/handle-tab-bunches': false, + 'background/handle-tab-focus': false, + 'background/handle-tab-multiselect': false, + 'background/handle-tree-changes': false, + 'background/migration': false, + 'background/native-tab-groups': false, + 'background/successor-tab': false, + 'background/tab-context-menu': false, + 'background/tabs-group': false, + 'background/tabs-move': false, + 'background/tabs-open': false, + 'background/tree': false, + 'background/tree-structure': false, + 'common/TreeItem': false, + 'common/Window': false, + 'common/api-tabs': false, + 'common/bookmark': false, + 'common/contextual-identities': false, + 'common/dialog': false, + 'common/permissions': false, + 'common/retrieve-url': false, + 'common/sidebar-connection': false, + 'common/sync': false, + 'common/tabs-internal-operation': false, + 'common/tabs-update': false, + 'common/tree-behavior': false, + 'common/tst-api': false, + 'common/unique-id': false, + 'common/user-operation-blocker': false, + 'sidebar/background-connection': false, + 'sidebar/collapse-expand': false, + 'sidebar/drag-and-drop': false, + 'sidebar/event-utils': false, + 'sidebar/gap-canceller': false, + 'sidebar/indent': false, + 'sidebar/mouse-event-listener': false, + 'sidebar/pinned-tabs': false, + 'sidebar/scroll': false, + 'sidebar/sidebar-items': false, + 'sidebar/sidebar': false, + 'sidebar/size': false, + 'sidebar/subpanel': false, + 'sidebar/tab-context-menu': false, + 'sidebar/tab-group-context-menu': false, + 'sidebar/tab-preview-tooltip': false, + 'sidebar/tst-api-frontend': false, + }, + loggingConnectionMessages: false, + enableLinuxBehaviors: false, + enableMacOSBehaviors: false, + enableWindowsBehaviors: false, + + + ...(Object.fromEntries(Array.from(obsoleteConfigs, key => [key, null]))), + + ...WATERFOX_SPECIFIC_VALUES, + + configsVersion: 0, + + testKey: 0 // for tests/utils.js +}, { + localKeys +}); + +configs.$addLocalLoadedObserver((key, value) => { + switch (key) { + case 'syncEnabled': + configs.sync = !!value; + return; + + default: + return; + } +}); + +// cleanup old data +browser.storage.sync.remove(localKeys); + +configs.$loaded.then(() => { + EventListenerManager.debug = configs.debug; + log.forceStore = false; + if (!configs.debug) + log.logs = []; +}); + + +export function loadUserStyleRules() { + return getChunkedConfig('chunkedUserStyleRules'); +} + +export function saveUserStyleRules(style) { + return setChunkedConfig('chunkedUserStyleRules', style); +} + +export function getChunkedConfig(key) { + const chunks = []; + let count = 0; + while (true) { + const slotKey = `${key}${count}`; + if (!(slotKey in configs)) + break; + chunks.push(configs[slotKey]); + count++; + } + return joinChunkedStrings(chunks); +} + +export function setChunkedConfig(key, value) { + let slotsSize = 0; + while (`${key}${slotsSize}` in configs.$default) { + slotsSize++; + } + + const chunks = chunkString(value, Constants.kSYNC_STORAGE_SAFE_QUOTA); + if (chunks.length > slotsSize) + throw new Error('too large data'); + + [...chunks, + ...Array.from(new Uint8Array(slotsSize), _ => '')] + .slice(0, slotsSize) + .forEach((chunk, index) => { + const slotKey = `${key}${index}`; + if (slotKey in configs) + configs[slotKey] = chunk || ''; + }); +} + +function chunkString(input, maxBytes) { + let binaryString = btoa(Array.from(new TextEncoder().encode(input), c => String.fromCharCode(c)).join('')); + const chunks = []; + while (binaryString.length > 0) { + chunks.push(binaryString.slice(0, maxBytes)); + binaryString = binaryString.slice(maxBytes); + } + return chunks; +} + +function joinChunkedStrings(chunks) { + try { + const buffer = Uint8Array.from(atob(chunks.join('')).split('').map(bytes => bytes.charCodeAt(0))); + return new TextDecoder().decode(buffer); + } + catch(_error) { + return ''; + } +} + + +shouldApplyAnimation.onChanged = new EventListenerManager(); +shouldApplyAnimation.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); +shouldApplyAnimation.prefersReducedMotion.addListener(_event => { + shouldApplyAnimation.onChanged.dispatch(shouldApplyAnimation()); +}); +configs.$addObserver(key => { + switch(key) { + case 'animation': + case 'animationForce': + shouldApplyAnimation.onChanged.dispatch(shouldApplyAnimation()); + break; + + case 'debug': + EventListenerManager.debug = configs[key]; + break; + } +}); + +// Some animation effects like smooth scrolling are still active even if it matches to "prefers-reduced-motion: reduce". +// So this function provides ability to ignore the media query result. +export function shouldApplyAnimation(configOnly = false) { + if (!configs.animation) + return false; + return configOnly || configs.animationForce || !shouldApplyAnimation.prefersReducedMotion.matches; +} + + +export function log(module, ...args) +{ + const isModuleLog = module in configs.$default.logFor; + const message = isModuleLog ? args.shift() : module ; + const useConsole = configs?.debug && (!isModuleLog || configs.logFor[module]); + const logging = useConsole || log.forceStore; + if (!logging) + return; + + args = args.map(arg => typeof arg == 'function' ? arg() : arg); + + const nest = (new Error()).stack.split('\n').length; + let indent = ''; + for (let i = 0; i < nest; i++) { + indent += ' '; + } + if (isModuleLog) + module = `${module}: ` + else + module = ''; + + const timestamp = configs.logTimestamp ? `${getTimeStamp()} ` : ''; + const line = `tst<${log.context}>: ${timestamp}${module}${indent}${message}`; + if (useConsole) + console.log(line, ...args); + + log.logs.push(`${line} ${args.reduce((output, arg, index) => { + output += `${index == 0 ? '' : ', '}${uneval(arg)}`; + return output; + }, '')}`); + log.logs = log.logs.slice(-log.max); +} +log.context = '?'; +log.max = 2000; +log.logs = []; +log.forceStore = true; + +// uneval() is no more available after https://bugzilla.mozilla.org/show_bug.cgi?id=1565170 +function uneval(value) { + switch (typeof value) { + case 'undefined': + return 'undefined'; + + case 'function': + return value.toString(); + + case 'object': + if (!value) + return 'null'; + default: + try { + return JSON.stringify(value); + } + catch(e) { + return `${String(value)} (couldn't be stringified due to an error: ${String(e)})`; + } + } +} + +function getTimeStamp() { + const time = new Date(); + const hours = `0${time.getHours()}`.slice(-2); + const minutes = `0${time.getMinutes()}`.slice(-2); + const seconds = `0${time.getSeconds()}`.slice(-2); + const milliseconds = `00${time.getMilliseconds()}`.slice(-3); + return `${hours}:${minutes}:${seconds}.${milliseconds}`; +} + +configs.$logger = log; + +export function dumpTab(tab) { + if (!configs || !configs.debug) + return ''; + if (!tab) + return ''; + return `#${tab.id}(${!!tab.$TST ? 'tracked' : '!tracked'})`; +} + +export async function wait(task = 0, timeout = 0) { + if (typeof task != 'function') { + timeout = task; + task = null; + } + return new Promise((resolve, _reject) => { + setTimeout(async () => { + if (task) + await task(); + resolve(); + }, timeout); + }); +} + +export function nextFrame() { + return new Promise((resolve, _reject) => { + window.requestAnimationFrame(resolve); + }); +} + +export async function asyncRunWithTimeout({ task, timeout, onTimedOut }) { + let succeeded = false; + return Promise.race([ + task().then(result => { + succeeded = true; + return result; + }), + wait(timeout).then(() => { + if (!succeeded) + return onTimedOut(); + }), + ]); +} + + +const mNotificationTasks = new Map(); + +function destroyNotificationTask(task) { + if (!mNotificationTasks.has(task.id)) + return; + + mNotificationTasks.delete(task.id); + + const resolve = task.resolve; + const url = task.url; + + task.id = undefined; + task.url = undefined; + task.resolve = undefined; + + return { resolve, url }; +} + +function onNotificationClicked(notificationId) { + const task = mNotificationTasks.get(notificationId); + if (!task) + return; + + const { resolve, url } = destroyNotificationTask(task); + if (url) { + browser.tabs.create({ + url + }); + } + resolve(true); +} +browser.notifications.onClicked.addListener(onNotificationClicked); + +function onNotificationClosed(notificationId) { + const task = mNotificationTasks.get(notificationId); + if (!task) + return; + + const { resolve } = destroyNotificationTask(task); + resolve(false); +} +browser.notifications.onClosed.addListener(onNotificationClosed); + +export async function notify({ icon, title, message, timeout, url } = {}) { + const id = await browser.notifications.create({ + type: 'basic', + iconUrl: icon || Constants.kNOTIFICATION_DEFAULT_ICON, + title, + message + }); + + const task = { id, url }; + mNotificationTasks.set(id, task); + + return new Promise(async (resolve, _reject) => { + task.resolve = resolve; + + if (typeof timeout != 'number') + timeout = configs.notificationTimeout; + if (timeout >= 0) { + await wait(timeout); + } + await browser.notifications.clear(id); + if (task.resolve) { + destroyNotificationTask(task); + resolve(false); + } + }).then(clicked => { + return clicked; + }); +} + + +export function tryRevokeObjectURL(url) { + if (!url.startsWith(`blob:${browser.runtime.getURL('') }`)) + return; + + try { + URL.revokeObjectURL(url); + } + catch(error) { + console.log('tryRevokeObjectURL failed: ', error); + } +} + + +export function compareAsNumber(a, b) { + return a - b; +} + + +// Helper functions for optimization +// Originally implemented by @bb010g at +// https://github.com/piroor/treestyletab/pull/2368/commits/9d184c4ac6c9977d2557cd17cec8c2a0f21dd527 + +// For better performance the callback function must return "undefined" +// when the item should not be included. "null", "false", and other false +// values will be included to the mapped result. +export function mapAndFilter(values, mapper) { + /* This function logically equals to: + return values.reduce((mappedValues, value) => { + value = mapper(value); + if (value !== undefined) + mappedValues.push(value); + return mappedValues; + }, []); + */ + const maxi = ('length' in values ? values.length : values.size) >>> 0; // define as unsigned int + const mappedValues = new Array(maxi); // prepare with enough size at first, to avoid needless re-allocation + let count = 0, + value, // this must be defined outside of the loop, to avoid needless re-allocation + mappedValue; // this must be defined outside of the loop, to avoid needless re-allocation + for (value of values) { + mappedValue = mapper(value); + if (mappedValue !== undefined) + mappedValues[count++] = mappedValue; + } + mappedValues.length = count; // shrink the array at last + return mappedValues; +} + +export function mapAndFilterUniq(values, mapper, options = {}) { + const mappedValues = new Set(); + let value, // this must be defined outside of the loop, to avoid needless re-allocation + mappedValue; // this must be defined outside of the loop, to avoid needless re-allocation + for (value of values) { + mappedValue = mapper(value); + if (mappedValue !== undefined) + mappedValues.add(mappedValue); + } + return options.set ? mappedValues : Array.from(mappedValues); +} + +export function countMatched(values, matcher) { + /* This function logically equals to: + return values.reduce((count, value) => { + if (matcher(value)) + count++; + return count; + }, 0); + */ + let count = 0, + value; // this must be defined outside of the loop, to avoid needless re-allocation + for (value of values) { + if (matcher(value)) + count++; + } + return count; +} + +export function toLines(values, mapper, separator = '\n') { + /* This function logically equals to: + return values.reduce((output, value, index) => { + output += `${index == 0 ? '' : '\n'}${mapper(value)}`; + return output; + }, ''); + */ + const maxi = values.length >>> 0; // define as unsigned int + let i = 0, + lines = ''; + while (i < maxi) { // use "while" loop instead "for" loop, for better performance + lines += `${i == 0 ? '' : separator}${mapper(values[i])}`; + i++; + } + return lines; +} + +export async function doProgressively(tabs, task, interval) { + interval = Math.max(0, interval); + let lastStartAt = Date.now(); + const results = []; + for (const tab of tabs) { + results.push(task(tab)); + if (interval && (Date.now() - lastStartAt >= interval)) { + await wait(50); + lastStartAt = Date.now(); + } + } + return Promise.all(results); +} + + +let useLegacyOverflowEvents = false; + +export function watchOverflowStateChange({ target, moreResizeTargets, onOverflow, onUnderflow, horizontal, vertical }) { + if (!horizontal && !vertical) + return; + + onOverflow = onOverflow || (() => {}); + onUnderflow = onUnderflow || (() => {}); + + let lastOverflow = null; + let invoked = false; + const onObserved = () => { + if (invoked) + return; + invoked = true; + window.requestAnimationFrame(() => { + invoked = false; + + const overflow = ( + (horizontal && isOverflowHorizontally(target)) || + (vertical && isOverflowVertically(target)) + ); + if (overflow === lastOverflow) + return; + + lastOverflow = overflow; + + if (overflow) { + onOverflow(); + } + else { + onUnderflow(); + } + }); + }; + + let resizeObserver/*, mutationObserver*/; + if (!useLegacyOverflowEvents) { + const resizeTargets = new Set([target, ...(moreResizeTargets || [])]); + resizeObserver = new ResizeObserver(entries => { + for (const entry of entries) { + if (!resizeTargets.has(entry.target)) + continue; + onObserved(); + } + }); + for (const resizeTarget of resizeTargets) { + resizeObserver.observe(resizeTarget); + } + + /* + // ResizeObserver won't observe changes of scrollHeight/Width, + // Observing changes of the DOM tree can be workaround. + mutationObserver = new MutationObserver(mutations => { + for (let mutation of mutations) { + if (mutation.type != 'childList' && + mutation.type != 'subtree') + continue; + onObserved(); + } + }); + mutationObserver.observe(target, { + childList: true, + subtree: true, + }); + */ + } + + const destroyObserver = () => { + if (!resizeObserver) + return; + resizeObserver.disconnect(); + resizeObserver = null; + /* + mutationObserver.disconnect(); + mutationObserver = null; + */ + }; + + // Legacy method for Firefox 127 or older. + // See also: https://bugzilla.mozilla.org/show_bug.cgi?id=1888737 + const onOverflowEvent = event => { + if (!useLegacyOverflowEvents) { + useLegacyOverflowEvents = true; + destroyObserver(); + } + if (event.target != target) + return; + if (event.type == 'overflow') + onOverflow(); + else + onUnderflow(); + }; + target.addEventListener('overflow', onOverflowEvent); + target.addEventListener('underflow', onOverflowEvent); + + const unwatch = () => { + target.removeEventListener('overflow', onOverflowEvent); + target.removeEventListener('underflow', onOverflowEvent); + destroyObserver(); + }; + return unwatch; +} + +function isOverflowHorizontally(target) { + return target.scrollWidth > target.clientWidth; +} + +function isOverflowVertically(target) { + return target.scrollHeight > target.clientHeight; +} + + +export async function sha1sum(string) { + const encoder = new TextEncoder(); + const data = encoder.encode(string); + const hashBuffer = await crypto.subtle.digest('SHA-1', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(byte => byte.toString(16).padStart(2, '0')).join(''); + return hashHex; +} + +export function sanitizeForHTMLText(text) { + return (text || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +export function sanitizeForRegExpSource(source) { // https://stackoverflow.com/questions/6300183/sanitize-string-of-regex-characters-before-regexp-build + return source.replace(/[#-.]|[[-^]|[?|{}]/g, '\\$&'); +} + +export function sanitizeAccesskeyMark(label) { + return String(label || '').replace(/\(&[a-z]\)|&([a-z])/gi, '$1'); +} + +export function getWindowParamsFromSource(sourceWindow, { left, top, width, height } = {}) { + const params = { + // inherit properties of the source window + incognito: sourceWindow.incognito, + state: sourceWindow.state, + width: sourceWindow.width, + height: sourceWindow.height, + left: sourceWindow.left, + top: sourceWindow.top, + }; + if (typeof left == 'number') + params.left = left; + if (typeof top == 'number') + params.top = top; + if (typeof width == 'number') + params.width = width; + if (typeof height == 'number') + params.height = height; + if (params.state == 'fullscreen' || + params.state == 'maximized') { + delete params.left; + delete params.top; + delete params.width; + delete params.height; + } + return params; +} + +export function isNewTabCommandTab(tab) { + const newTabTitles = new Set(configs.guessNewOrphanTabAsOpenedByNewTabCommandTitle.split('|')); + const newTabUrls = new Set(configs.guessNewOrphanTabAsOpenedByNewTabCommandUrl.split('|')); + return newTabTitles.has(tab?.title) || newTabUrls.has(tab?.url); +} + +export function isFirefoxViewTab(tab) { + return ( + tab?.url.startsWith('about:firefoxview') && + tab?.hidden + ); +} + + +export function waitUntilStartupOperationsUnblocked() { + if (!configs.blockStartupOperations) + return; + + return new Promise((resolve, _reject) => { + const waitUntilUblocked = key => { + if (key != 'blockStartupOperations' || + configs[key]) + return; + configs.$removeObserver(waitUntilUblocked); + resolve(); + }; + configs.$addObserver(waitUntilUblocked); + }); +} + + +export function isLinux() { + return configs.enableLinuxBehaviors || /^Linux/i.test(navigator.platform); +} + +export function isMacOS() { + return configs.enableMacOSBehaviors || /^Mac/i.test(navigator.platform); +} + +export function isWindows() { + return configs.enableWindowsBehaviors || /^Win/i.test(navigator.platform); +} diff --git a/waterfox/browser/components/sidebar/common/constants.js b/waterfox/browser/components/sidebar/common/constants.js new file mode 100644 index 000000000000..5cbf8de56df1 --- /dev/null +++ b/waterfox/browser/components/sidebar/common/constants.js @@ -0,0 +1,482 @@ +/* +# 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'; + +export const kCOMMAND_GET_INSTANCE_ID = 'ws:get-instance-id'; +export const kCOMMAND_RELOAD = 'ws:reload'; +export const kCOMMAND_PING_TO_BACKGROUND = 'ws:ping-to-background'; +export const kCOMMAND_PING_TO_SIDEBAR = 'ws:ping-to-sidebar'; +export const kCOMMAND_REQUEST_CONNECT_PREFIX = 'ws:request-connect-from:'; +export const kCOMMAND_REQUEST_UNIQUE_ID = 'ws:request-unique-id'; +export const kCOMMAND_GET_THEME_DECLARATIONS = 'ws:get-theme-declarations'; +export const kCOMMAND_GET_CONTEXTUAL_IDENTITIES_COLOR_INFO = 'ws:get-contextual-identities-color-info'; +export const kCOMMAND_GET_CONFIG_VALUE = 'ws:get-config-value'; +export const kCOMMAND_SET_CONFIG_VALUE = 'ws:set-config-value'; +export const kCOMMAND_GET_USER_STYLE_RULES = 'ws:get-user-style-rules'; +export const kCOMMAND_PULL_TABS = 'ws:pull-tabs'; +export const kCOMMAND_SYNC_TABS_ORDER = 'ws:sync-tabs-order'; +export const kCOMMAND_PULL_TABS_ORDER = 'ws:pull-tabs-order'; +export const kCOMMAND_PULL_TREE_STRUCTURE = 'ws:pull-tree-structure'; +export const kCOMMAND_GET_RENDERED_TAB_IDS = 'ws:get-rendered-tab-ids'; +export const kCOMMAND_ASK_TAB_IS_IN_VIEWPORT = 'ws:ask-tab-is-in-viewport'; +export const kCOMMAND_LOAD_URI = 'ws:load-uri'; +export const kCOMMAND_OPEN_TAB = 'ws:open-tab'; +export const kCOMMAND_NEW_WINDOW_FROM_TABS = 'ws:open-new-window-from-tabs'; +export const kCOMMAND_NEW_TABS = 'ws:open-new-tabs'; +export const kCOMMAND_NEW_TAB_AS = 'ws:open-new-tab-as'; +export const kCOMMAND_REMOVE_TABS_BY_MOUSE_OPERATION = 'ws:remove-tabs-by-mouse-operation'; +export const kCOMMAND_REMOVE_TABS_INTERNALLY = 'ws:remove-tabs-internally'; +export const kCOMMAND_UPDATE_LOADING_STATE = 'ws:update-loading-state'; +export const kCOMMAND_CONFIRM_TO_CLOSE_TABS = 'ws:confirm-to-close-tabs'; +export const kCOMMAND_SHOW_DIALOG = 'ws:show-dialog'; +export const kCOMMAND_NOTIFY_BACKGROUND_READY = 'ws:notify-background-ready'; +export const kCOMMAND_NOTIFY_CONNECTION_READY = 'ws:notify-connection-ready'; +export const kCOMMAND_NOTIFY_SIDEBAR_CLOSED = 'ws:notify-sidebar-closed'; +export const kCOMMAND_NOTIFY_TAB_CREATING = 'ws:notify-tab-creating'; +export const kCOMMAND_NOTIFY_TAB_CREATED = 'ws:notify-tab-created'; +export const kCOMMAND_NOTIFY_TAB_UPDATED = 'ws:notify-tab-updated'; +export const kCOMMAND_NOTIFY_TAB_MOVED = 'ws:notify-tab-moved'; +export const kCOMMAND_NOTIFY_TAB_INTERNALLY_MOVED = 'ws:notify-tab-internally-moved'; +export const kCOMMAND_NOTIFY_TAB_REMOVING = 'ws:notify-tab-removing'; +export const kCOMMAND_NOTIFY_TAB_REMOVED = 'ws:notify-tab-removed'; +export const kCOMMAND_NOTIFY_TAB_ACTIVATING = 'ws:notify-tab-activating'; +export const kCOMMAND_NOTIFY_TAB_ACTIVATED = 'ws:notify-tab-activated'; +export const kCOMMAND_NOTIFY_MAY_START_TAB_SWITCH = 'ws:notify-may-start-tab-switch'; +export const kCOMMAND_NOTIFY_MAY_END_TAB_SWITCH = 'ws:notify-may-end-tab-switch'; +export const kCOMMAND_NOTIFY_TAB_ATTACHED_TO_WINDOW = 'ws:notify-tab-attached-to-window'; +export const kCOMMAND_NOTIFY_TAB_DETACHED_FROM_WINDOW = 'ws:notify-tab-detached-from-window'; +export const kCOMMAND_NOTIFY_PERMISSIONS_GRANTED = 'ws:notify-permissions-granted'; +export const kCOMMAND_NOTIFY_TAB_PINNED = 'ws:notify-tab-pinned'; +export const kCOMMAND_NOTIFY_TAB_UNPINNED = 'ws:notify-tab-unpinned'; +export const kCOMMAND_NOTIFY_TAB_SHOWN = 'ws:notify-tab-shown'; +export const kCOMMAND_NOTIFY_TAB_HIDDEN = 'ws:notify-tab-hidden'; +export const kCOMMAND_NOTIFY_TAB_RESTORING = 'ws:notify-tab-restoring'; +export const kCOMMAND_NOTIFY_TAB_RESTORED = 'ws:notify-tab-restored'; +export const kCOMMAND_NOTIFY_TREE_ITEM_LABEL_UPDATED = 'ws:notify-tab-label-updated'; +export const kCOMMAND_NOTIFY_TAB_FAVICON_UPDATED = 'ws:notify-tab-favicon-updated'; +export const kCOMMAND_NOTIFY_TAB_SOUND_STATE_UPDATED = 'ws:notify-tab-sound-state-updated'; +export const kCOMMAND_NOTIFY_TAB_SHARING_STATE_UPDATED = 'ws:notify-tab-sharing-state-updated'; +export const kCOMMAND_NOTIFY_TABS_CLOSING = 'ws:notify-tabs-closing'; +export const kCOMMAND_NOTIFY_HIGHLIGHTED_TABS_CHANGED = 'ws:notify-highlighted-tabs-changed'; +export const kCOMMAND_NOTIFY_TABS_HIGHLIGHTING_IN_PROGRESS = 'ws:notify-tabs-highlighting-in-progress'; +export const kCOMMAND_NOTIFY_TABS_HIGHLIGHTING_COMPLETE = 'ws:notify-tabs-highlighting-complete'; +export const kCOMMAND_NOTIFY_GROUP_TAB_DETECTED = 'ws:notify-group-tab-detected'; +export const kCOMMAND_NOTIFY_CHILDREN_CHANGED = 'ws:notify-children-changed'; +export const kCOMMAND_NOTIFY_TAB_COLLAPSED_STATE_CHANGED = 'ws:notify-tab-collapsed-state-changed'; +export const kCOMMAND_NOTIFY_SUBTREE_COLLAPSED_STATE_CHANGING = 'ws:notify-subtree-collapsed-state-changing'; +export const kCOMMAND_NOTIFY_SUBTREE_COLLAPSED_STATE_CHANGED = 'ws:notify-subtree-collapsed-state-changed'; +export const kCOMMAND_NOTIFY_TAB_GROUP_CREATED = 'ws:notify-tab-group-created'; +export const kCOMMAND_NOTIFY_TAB_GROUP_UPDATED = 'ws:notify-tab-group-updated'; +export const kCOMMAND_NOTIFY_TAB_GROUP_REMOVED = 'ws:notify-tab-group-removed'; +export const kCOMMAND_SET_SUBTREE_COLLAPSED_STATE = 'ws:set-subtree-collapsed-state'; +export const kCOMMAND_SET_SUBTREE_COLLAPSED_STATE_INTELLIGENTLY_FOR = 'ws:set-subtree-collapsed-state-intelligently-for'; +export const kCOMMAND_TOGGLE_STICKY = 'ws:toggle-sticky'; +export const kCOMMAND_NOTIFY_TAB_LEVEL_CHANGED = 'ws:notify-tab-level-changed'; +export const kCOMMAND_NOTIFY_TAB_ATTACHED_COMPLETELY = 'ws:notify-tab-attached-completely'; +export const kCOMMAND_BROADCAST_CURRENT_DRAG_DATA = 'ws:broadcast-current-drag-data'; +export const kCOMMAND_SHOW_CONTAINER_SELECTOR = 'ws:show-container-selector'; +export const kCOMMAND_SCROLL_TABBAR = 'ws:scroll-tabbar'; +export const kCOMMAND_TOGGLE_SUBPANEL = 'ws:toggle-subpanel'; +export const kCOMMAND_SWITCH_SUBPANEL = 'ws:switch-subpanel'; +export const kCOMMAND_INCREASE_SUBPANEL = 'ws:increase-subpanel'; +export const kCOMMAND_DECREASE_SUBPANEL = 'ws:decrease-subpanel'; +export const kCOMMAND_REQUEST_QUERY_LOGS = 'ws:request-query-logs'; +export const kCOMMAND_RESPONSE_QUERY_LOGS = 'ws:response-query-logs'; +export const kCOMMAND_REQUEST_CONNECTION_MESSAGE_LOGS = 'ws:request-connection-message-logs'; +export const kCOMMAND_RESPONSE_CONNECTION_MESSAGE_LOGS = 'ws:response-connection-message-logs'; +export const kCOMMAND_NOTIFY_TEST_KEY_CHANGED = 'ws:notify-test-key-changed'; +export const kCOMMAND_SIMULATE_SIDEBAR_MESSAGE = 'ws:simulate-sidebar-message'; +export const kCOMMAND_GET_CONTEXT_MENU_ITEMS = 'ws:contextMenu-get-items'; +export const kCOMMAND_NOTIFY_CONTEXT_MENU_UPDATED = 'ws:contextMenu-updated'; +export const kCOMMAND_NOTIFY_CONTEXT_ITEM_CHECKED_STATUS_CHANGED = 'ws:contextMenu-item-checked-status-changed'; +export const kCOMMAND_NOTIFY_CONTEXT_OVERRIDDEN = 'ws:notify-context-overridden'; +export const kCOMMAND_AUTODETECT_DUPLICATED_TAB_DETECTION_DELAY = 'ws:autodetect-duplicated-tab-detection-delay'; +export const kCOMMAND_TEST_DUPLICATED_TAB_DETECTION = 'ws:test-duplicated-tab-detection'; +export const kCOMMAND_WAIT_UNTIL_SUCCESSORS_UPDATED = 'ws:wait-until-successors-updated'; +export const kCOMMAND_GET_SIDEBAR_POSITION = 'ws:get-sidebar-position'; +export const kCOMMAND_GET_ABOVE_TAB = 'ws:get-above-tab'; +export const kCOMMAND_GET_BELOW_TAB = 'ws:get-below-tab'; +export const kCOMMAND_GET_LEFT_TAB = 'ws:get-left-tab'; +export const kCOMMAND_GET_RIGHT_TAB = 'ws:get-right-tab'; +export const kCOMMAND_GET_BOUNDING_CLIENT_RECT = 'ws:get-bounding-client-rect'; +export const kCOMMAND_SHOW_NATIVE_TAB_GROUP_MENU_PANEL = 'ws:show-native-tab-group-menu-panel'; +export const kCOMMAND_UPDATE_NATIVE_TAB_GROUP = 'ws:update-native-tab-group'; +export const kCOMMAND_INVOKE_NATIVE_TAB_GROUP_MENU_PANEL_COMMAND = 'ws:invoke-native-tab-group-menu-panel-command'; +export const kCOMMAND_NEW_WINDOW_FROM_NATIVE_TAB_GROUP = 'ws:new-window-from-native-tab-group'; + +export const kCOMMAND_ACTIVATE_TAB = 'ws:activate-tab'; +export const kCOMMAND_HIGHLIGHT_TABS = 'ws:highlight-tabs'; +export const kCOMMAND_TOGGLE_MUTED_FROM_SOUND_BUTTON = 'ws:toggle-muted-from-sound-button'; +export const kCOMMAND_UNBLOCK_AUTOPLAY_FROM_SOUND_BUTTON = 'ws:unblock-autoplay-from-sound-button'; +export const kCOMMAND_PERFORM_TABS_DRAG_DROP = 'ws:perform-tabs-drag-drop'; +export const kCOMMAND_BLOCK_USER_OPERATIONS = 'ws:block-user-operations'; +export const kCOMMAND_UNBLOCK_USER_OPERATIONS = 'ws:unblock-user-operations'; +export const kCOMMAND_PROGRESS_USER_OPERATIONS = 'ws:progress-user-operations'; +export const kCOMMAND_BROADCAST_TAB_STATE = 'ws:broadcast-tab-state'; +export const kCOMMAND_BROADCAST_TAB_TOOLTIP_TEXT = 'ws:broadcast-tab-tooltip-text'; +export const kCOMMAND_BROADCAST_TAB_AUTO_STICKY_STATE = 'ws:broadcast-tab-auto-sticky-state'; + +export const kCOMMAND_BOOKMARK_TAB_WITH_DIALOG = 'ws:bookmark-tab-with-dialog'; +export const kCOMMAND_BOOKMARK_TABS_WITH_DIALOG = 'ws:bookmark-tabs-with-dialog'; + +export const kNOTIFY_TAB_MOUSEDOWN = 'ws:tab-mousedown'; +export const kNOTIFY_TAB_MOUSEDOWN_EXPIRED = 'ws:tab-mousedown-expired'; + +export const kNOTIFY_SIDEBAR_FOCUS = 'ws:sidebar-focus'; +export const kNOTIFY_SIDEBAR_BLUR = 'ws:sidebar-blur'; + +export const kNOTIFY_CONFIRMATION_DIALOG_READY = 'ws:confirmation-dialog-ready'; + +export const kCONNECTION_HEARTBEAT = 'ws:connection-heartbeat'; + +export const kAPI_TAB_ID = 'data-tab-id'; +export const kAPI_WINDOW_ID = 'data-window-id'; +export const kAPI_NATIVE_TAB_GROUP_ID = 'data-native-tab-group-id'; + +export const kGROUP_ID = 'data-group-id'; +export const kPARENT = 'data-parent-id'; +export const kCHILDREN = 'data-child-ids'; +export const kLEVEL = 'data-level'; +export const kCLOSED_SET_ID = 'data-closed-set-id'; +export const kCURRENT_URI = 'data-current-uri'; +export const kCURRENT_FAVICON_URI = 'data-current-favicon-uri'; +export const kCONTEXTUAL_IDENTITY_NAME = 'data-contextual-identity-name'; +export const kMAX_TREE_LEVEL = 'data-max-tree-level'; +export const kLABEL_OVERFLOW = 'data-label-overflow'; + +export const kPERSISTENT_ID = 'data-persistent-id'; +export const kPERSISTENT_ANCESTORS = 'ancestors'; +export const kPERSISTENT_CHILDREN = 'children'; +export const kPERSISTENT_INSERT_BEFORE = 'insert-before'; +export const kPERSISTENT_INSERT_AFTER = 'insert-after'; +export const kPERSISTENT_INSERT_AFTER_LEGACY = 'isnert-after'; +export const kPERSISTENT_STATES = 'special-tab-states'; +export const kPERSISTENT_SUBTREE_COLLAPSED = 'subtree-collapsed'; // obsolete +export const kPERSISTENT_ORIGINAL_OPENER_TAB_ID = 'data-original-opener-tab-id'; +export const kPERSISTENT_ALREADY_GROUPED_FOR_PINNED_OPENER = 'data-already-grouped-for-pinned-opener'; + +export const kFAVICON_IMAGE = 'favicon-image'; +export const kFAVICON_BUILTIN = 'favicon-builtin'; +export const kFAVICON_DEFAULT = 'favicon-default'; // just for backward compatibility, and this should be removed from future versions +export const kFAVICON_SHARING_STATE = 'favicon-sharing-state'; +export const kFAVICON_STICKY_STATE = 'favicon-sticky-state'; +export const kBACKGROUND = 'background'; +export const kTHROBBER = 'throbber'; +export const kHIGHLIGHTER = 'highlighter'; +export const kBURSTER = 'burster'; +export const kNEWTAB_BUTTON = 'newtab-button'; +export const kEXTRA_ITEMS_CONTAINER = 'extra-items-container'; +export const kCONTEXTUAL_IDENTITY_MARKER = 'contextual-identity-marker'; +export const kCONTEXTUAL_IDENTITY_SELECTOR = 'contextual-identities-selector'; +export const kCONTEXTUAL_IDENTITY_SELECTOR_CONTEXT_MENU = 'contextual-identities-selector-context'; +export const kNEWTAB_ACTION_SELECTOR = 'newtab-action-selector'; +export const kTABBAR_SPACER = 'tabs-spacer'; + +export const kTAB_STATE_ACTIVE = 'active'; +export const kTAB_STATE_PINNED = 'pinned'; +export const kTAB_STATE_LAST_ROW = 'last-row'; +export const kTAB_STATE_LAST_VISIBLE = 'last-visible'; +export const kTAB_STATE_AUDIBLE = 'audible'; +export const kTAB_STATE_SOUND_PLAYING = 'sound-playing'; +export const kTAB_STATE_HAS_SOUND_PLAYING_MEMBER = 'has-sound-playing-member'; +export const kTAB_STATE_MUTED = 'muted'; +export const kTAB_STATE_HAS_MUTED_MEMBER = 'has-muted-member'; +export const kTAB_STATE_AUTOPLAY_BLOCKED = 'autoplay-blocked'; +export const kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER = 'has-autoplay-blocked-member'; +export const kTAB_STATE_PRIVATE_BROWSING = 'private-browsing'; +export const kTAB_STATE_HIDDEN = 'hidden'; +export const kTAB_STATE_SHARING_CAMERA = 'sharing-camera'; +export const kTAB_STATE_SHARING_MICROPHONE = 'sharing-microphone'; +export const kTAB_STATE_SHARING_SCREEN = 'sharing-screen'; +export const kTAB_STATE_HAS_SHARING_CAMERA_MEMBER = 'has-sharing-camera-member'; +export const kTAB_STATE_HAS_SHARING_MICROPHONE_MEMBER = 'has-sharing-microphone-member'; +export const kTAB_STATE_HAS_SHARING_SCREEN_MEMBER = 'has-sharing-screen-member'; +export const kTAB_STATE_ANIMATION_READY = 'animation-ready'; +export const kTAB_STATE_NOT_ACTIVATED_SINCE_LOAD = 'not-activated-since-load'; +export const kTAB_STATE_BURSTING = 'bursting'; +export const kTAB_STATE_CREATING = 'creating'; +export const kTAB_STATE_TO_BE_REMOVED = 'to-be-removed'; +export const kTAB_STATE_REMOVING = 'removing'; +export const kTAB_STATE_COLLAPSED = 'collapsed'; +export const kTAB_STATE_COLLAPSED_DONE = 'collapsed-completely'; +export const kTAB_STATE_COLLAPSING = 'collapsing'; +export const kTAB_STATE_EXPANDING = 'expanding'; +export const kTAB_STATE_MOVING = 'moving'; +export const kTAB_STATE_SHOWING = 'showing'; +export const kTAB_STATE_SUBTREE_COLLAPSED = 'subtree-collapsed'; +export const kTAB_STATE_SUBTREE_EXPANDED_MANUALLY = 'subtree-expanded-manually'; +export const kTAB_STATE_FAVICONIZED = 'faviconized'; +export const kTAB_STATE_UNREAD = 'unread'; +export const kTAB_STATE_PENDING = 'pending'; +export const kTAB_STATE_HIGHLIGHTED = 'highlighted'; +export const kTAB_STATE_BUNDLED_ACTIVE = 'bundled-active'; +export const kTAB_STATE_SOME_DESCENDANTS_HIGHLIGHTED = 'some-descendants-highlighted'; +export const kTAB_STATE_ALL_DESCENDANTS_HIGHLIGHTED = 'all-descendants-highlighted'; +export const kTAB_STATE_ATTENTION = 'attention'; +export const kTAB_STATE_DISCARDED = 'discarded'; +export const kTAB_STATE_SELECTED = 'selected'; +export const kTAB_STATE_DRAGGING = 'dragging'; +export const kTAB_STATE_DUPLICATING = 'duplicating'; +export const kTAB_STATE_DUPLICATED = 'duplicated'; +export const kTAB_STATE_RESTORED = 'restored'; +export const kTAB_STATE_FROM_EXTERNAL = 'from-external'; +export const kTAB_STATE_FROM_FIREFOX_VIEW = 'from-firefox-view'; +export const kTAB_STATE_THROBBER_UNSYNCHRONIZED = 'throbber-unsynchronized'; +export const kTAB_STATE_GROUP_TAB = 'group-tab'; +export const kTAB_STATE_NEW_TAB_COMMAND_TAB = 'newtab-command-tab'; +export const kTAB_STATE_OPENED_FOR_SAME_WEBSITE = 'opened-for-same-website'; +export const kTAB_STATE_STICKY = 'sticky'; +export const kTAB_STATE_STUCK = 'stuck'; // virtual state +export const kTAB_INTERNAL_STATES = new Set([ // TST specific states + 'tab', + kTAB_STATE_LAST_ROW, + kTAB_STATE_LAST_VISIBLE, + kTAB_STATE_ANIMATION_READY, + kTAB_STATE_COLLAPSED_DONE, + kTAB_STATE_CREATING, + kTAB_STATE_DUPLICATING, + kTAB_STATE_COLLAPSING, + kTAB_STATE_EXPANDING, + kTAB_STATE_MOVING, + kTAB_STATE_TO_BE_REMOVED, + kTAB_STATE_REMOVING, + kTAB_STATE_SHOWING, + kTAB_STATE_THROBBER_UNSYNCHRONIZED, + kTAB_STATE_NEW_TAB_COMMAND_TAB, + kTAB_STATE_DUPLICATED, + kTAB_STATE_RESTORED, + kTAB_STATE_FROM_EXTERNAL, + kTAB_STATE_FROM_FIREFOX_VIEW, + kTAB_STATE_OPENED_FOR_SAME_WEBSITE, + kTAB_STATE_STICKY, + kTAB_STATE_STUCK, + kTAB_STATE_PENDING, +]); +export const kTAB_TEMPORARY_STATES = new Set([ // states not trigger updating of cache + kTAB_STATE_CREATING, + kTAB_STATE_BURSTING, + kTAB_STATE_COLLAPSING, + kTAB_STATE_DUPLICATING, + kTAB_STATE_EXPANDING, + kTAB_STATE_MOVING, + kTAB_STATE_TO_BE_REMOVED, + kTAB_STATE_REMOVING, + kTAB_STATE_SHOWING, + kTAB_STATE_NEW_TAB_COMMAND_TAB, + kTAB_STATE_DUPLICATED, + kTAB_STATE_RESTORED, + kTAB_STATE_FROM_EXTERNAL, + kTAB_STATE_FROM_FIREFOX_VIEW, + kTAB_STATE_OPENED_FOR_SAME_WEBSITE, + kTAB_STATE_PENDING, +]); +export const kTAB_SAFE_STATES = new Set([ // exportable via API + kTAB_STATE_COLLAPSED, + kTAB_STATE_SUBTREE_COLLAPSED, + kTAB_STATE_GROUP_TAB, + kTAB_STATE_CREATING, + kTAB_STATE_NEW_TAB_COMMAND_TAB, + kTAB_STATE_DUPLICATED, + kTAB_STATE_RESTORED, + kTAB_STATE_FROM_EXTERNAL, + kTAB_STATE_FROM_FIREFOX_VIEW, + kTAB_STATE_OPENED_FOR_SAME_WEBSITE, + kTAB_STATE_STICKY, + kTAB_STATE_STUCK, +]); +export const kTAB_SAFE_STATES_ARRAY = Array.from(kTAB_SAFE_STATES); + +export const kTABBAR_STATE_OVERFLOW = 'overflow'; +export const kTABBAR_STATE_SCROLLED = 'scrolled'; +export const kTABBAR_STATE_FULLY_SCROLLED = 'fully-scrolled'; +export const kTABBAR_STATE_BLOCKING = 'blocking'; +export const kTABBAR_STATE_BLOCKING_WITH_THROBBER = 'blocking-throbber'; +export const kTABBAR_STATE_BLOCKING_WITH_SHADE = 'blocking-shade'; +export const kTABBAR_STATE_HAVE_LOADING_TAB = 'have-loading-tab'; +export const kTABBAR_STATE_HAVE_UNSYNCHRONIZED_THROBBER = 'have-unsynchronized-throbber'; +export const kTABBAR_STATE_THROBBER_SYNCHRONIZING = 'throbber-synchronizing'; +export const kTABBAR_STATE_CONTEXTUAL_IDENTITY_SELECTABLE = 'contextual-identity-selectable'; +export const kTABBAR_STATE_NEWTAB_ACTION_SELECTABLE = 'newtab-action-selectable'; +export const kTABBAR_STATE_MULTIPLE_HIGHLIGHTED = 'mutiple-highlighted'; +export const kTABBAR_STATE_HAS_VISUAL_GAP = 'has-visual-gap'; +export const kTABBAR_STATE_HOVER_ON_TOP_EDGE = 'hover-on-top-edge'; +export const kTABBAR_STATE_SCROLLBAR_AUTOHIDE = 'scrollbar-autohide'; +export const kTABBAR_STATE_FAVICONIZE_PINNED_TABS = 'faviconize-pinned-tabs'; +export const kTABBAR_STATE_TAB_DRAGGING = 'tab-dragging'; +export const kTABBAR_STATE_LINK_DRAGGING = 'link-dragging'; + +export const kWINDOW_STATE_TREE_STRUCTURE = 'tree-structure'; +export const kWINDOW_STATE_SCROLL_POSITION = 'scroll-position'; +export const kWINDOW_STATE_SUBPANEL_PROVIDER_ID = 'subpanel-provider-id'; +export const kWINDOW_STATE_SUBPANEL_HEIGHT = 'subpanel-height'; +export const kWINDOW_STATE_SUBPANEL_EFFECTIVE_HEIGHT = 'subpanel-effective-height'; +export const kWINDOW_STATE_CACHED_TABS = 'cached-tabs'; +export const kWINDOW_STATE_CACHED_SIDEBAR = 'cached-sidebar-contents'; +export const kWINDOW_STATE_CACHED_SIDEBAR_CONTENTS = 'cached-sidebar-contents:contents'; +export const kWINDOW_STATE_CACHED_SIDEBAR_TABS_DIRTY = 'cached-sidebar-contents:tabs-dirty'; +export const kWINDOW_STATE_CACHED_SIDEBAR_COLLAPSED_DIRTY = 'cached-sidebar-contents:collapsed-dirty'; + +export const kCACHE_KEYS = [ + kWINDOW_STATE_CACHED_TABS, + kWINDOW_STATE_CACHED_SIDEBAR, + kWINDOW_STATE_CACHED_SIDEBAR_CONTENTS, + kWINDOW_STATE_CACHED_SIDEBAR_TABS_DIRTY, + kWINDOW_STATE_CACHED_SIDEBAR_COLLAPSED_DIRTY, +]; + +export const kCOUNTER_ROLE_ALL_TABS = 1; +export const kCOUNTER_ROLE_CONTAINED_TABS = 2; + +export const kTABBAR_POSITION_AUTO = 0; +export const kTABBAR_POSITION_LEFT = 1; +export const kTABBAR_POSITION_RIGHT = 2; + +export const kACTION_MOVE = 1 << 0; +export const kACTION_ATTACH = 1 << 10; +export const kACTION_DETACH = 1 << 11; + +export const kDROPLINK_ASK = 0; +export const kDROPLINK_LOAD = 1 << 0; +export const kDROPLINK_NEWTAB = 1 << 1; + +export const kGROUP_BOOKMARK_ASK = 0; +export const kGROUP_BOOKMARK_SUBTREE = 1 << 0; +export const kGROUP_BOOKMARK_SEPARATE = 1 << 1; +export const kGROUP_BOOKMARK_FIXED = kGROUP_BOOKMARK_SUBTREE | kGROUP_BOOKMARK_SEPARATE; +export const kGROUP_BOOKMARK_USE_DUMMY = 1 << 8; +export const kGROUP_BOOKMARK_USE_DUMMY_FORCE = 1 << 10; +export const kGROUP_BOOKMARK_DONT_RESTORE_TREE_STRUCTURE = 1 << 9; +export const kGROUP_BOOKMARK_EXPAND_ALL_TREE = 1 << 11; +export const kGROUP_BOOKMARK_CANCEL = -1; + +export const kGROUP_TAB_TEMPORARY_STATE_NOTHING = 0; +export const kGROUP_TAB_TEMPORARY_STATE_PASSIVE = 1; +export const kGROUP_TAB_TEMPORARY_STATE_AGGRESSIVE = 2; + +export const kPARENT_TAB_OPERATION_BEHAVIOR_MODE_PARALLEL = 0; +export const kPARENT_TAB_OPERATION_BEHAVIOR_MODE_CONSISTENT = 1; +export const kPARENT_TAB_OPERATION_BEHAVIOR_MODE_CUSTOM = -1; + +export const kPARENT_TAB_OPERATION_CONTEXT_CLOSE = 1; +export const kPARENT_TAB_OPERATION_CONTEXT_MOVE = 2; + +export const kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE = 2; +export const kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD = 3; +export const kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN = 0; +export const kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY = 6; +export const kPARENT_TAB_OPERATION_BEHAVIOR_DETACH_ALL_CHILDREN = 1; +export const kPARENT_TAB_OPERATION_BEHAVIOR_SIMPLY_DETACH_ALL_CHILDREN = 4; // just for internal use +export const kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB = 5; + +// just for migration from old versions +export const kPARENT_TAB_BEHAVIOR_ALWAYS = 0; +export const kPARENT_TAB_BEHAVIOR_ONLY_WHEN_VISIBLE = 1; +export const kPARENT_TAB_BEHAVIOR_ONLY_ON_SIDEBAR = 2; + +export const kINSERT_NO_CONTROL = -1; +export const kINSERT_INHERIT = -2; +export const kINSERT_TOP = 0; +export const kINSERT_END = 1; +export const kINSERT_NEAREST = 2; +export const kINSERT_NEXT_TO_LAST_RELATED_TAB = 3; +export const kCONTROLLED_INSERTION_POSITION = new Set([ + kINSERT_TOP, + kINSERT_NEAREST, + kINSERT_NEXT_TO_LAST_RELATED_TAB, +]); + +export const kSUCCESSOR_TAB_CONTROL_NEVER = 0; +export const kSUCCESSOR_TAB_CONTROL_SIMULATE_DEFAULT = 1; +export const kSUCCESSOR_TAB_CONTROL_IN_TREE = 2; + +export const kTREE_DOUBLE_CLICK_BEHAVIOR_NONE = 0; +export const kTREE_DOUBLE_CLICK_BEHAVIOR_TOGGLE_COLLAPSED = 1; +export const kTREE_DOUBLE_CLICK_BEHAVIOR_TOGGLE_STICKY = 4; +export const kTREE_DOUBLE_CLICK_BEHAVIOR_CLOSE = 3; +// 2 is a retired number for a removed feature + +export const kDRAG_BEHAVIOR_NONE = 0; +export const kDRAG_BEHAVIOR_ENTIRE_TREE = 1 << 0; +export const kDRAG_BEHAVIOR_ALLOW_BOOKMARK = 1 << 1; +export const kDRAG_BEHAVIOR_TEAR_OFF = 1 << 2; +export const kDRAG_BEHAVIOR_MOVE = 1 << 3; + +export const kNEWTAB_DO_NOTHING = -1; +export const kNEWTAB_OPEN_AS_ORPHAN = 0; +export const kNEWTAB_OPEN_AS_CHILD = 1; // no control about position +export const kNEWTAB_OPEN_AS_CHILD_NEXT_TO_LAST_RELATED_TAB = 5; +export const kNEWTAB_OPEN_AS_CHILD_TOP = 6; +export const kNEWTAB_OPEN_AS_CHILD_END = 7; +export const kNEWTAB_OPEN_AS_SIBLING = 2; +export const kNEWTAB_OPEN_AS_NEXT_SIBLING = 3; +export const kNEWTAB_OPEN_AS_NEXT_SIBLING_WITH_INHERITED_CONTAINER = 4; +export const kCONTROLLED_NEWTAB_POSITION = new Set([ + kNEWTAB_OPEN_AS_CHILD, + kNEWTAB_OPEN_AS_CHILD_NEXT_TO_LAST_RELATED_TAB, + kNEWTAB_OPEN_AS_CHILD_TOP, + kNEWTAB_OPEN_AS_CHILD_END, + kNEWTAB_OPEN_AS_SIBLING, + kNEWTAB_OPEN_AS_NEXT_SIBLING, + kNEWTAB_OPEN_AS_NEXT_SIBLING_WITH_INHERITED_CONTAINER, +]); + +export const kCONTEXTUAL_IDENTITY_DEFAULT = 0; +export const kCONTEXTUAL_IDENTITY_FROM_PARENT = 1; +export const kCONTEXTUAL_IDENTITY_FROM_LAST_ACTIVE = 2; + +export const kSCROLL_TO_NEW_TAB_IGNORE = 0; +export const kSCROLL_TO_NEW_TAB_IF_POSSIBLE = 1; + +export const kTABBAR_UPDATE_REASON_RESIZE = 1 << 0; +export const kTABBAR_UPDATE_REASON_COLLAPSE = 1 << 1; +export const kTABBAR_UPDATE_REASON_EXPAND = 1 << 2; +export const kTABBAR_UPDATE_REASON_ANIMATION_END = 1 << 3; +export const kTABBAR_UPDATE_REASON_TAB_OPEN = 1 << 4; +export const kTABBAR_UPDATE_REASON_TAB_CLOSE = 1 << 5; +export const kTABBAR_UPDATE_REASON_TAB_MOVE = 1 << 6; +export const kTABBAR_UPDATE_REASON_VIRTUAL_SCROLL_VIEWPORT_UPDATE = 1 << 7; + +export const kDEFAULT_MIN_INDENT = 3; + +export const kGROUP_TAB_URI = browser.runtime.getURL('resources/group-tab.html'); +export const kGROUP_TAB_DEFAULT_TITLE_MATCHER = new RegExp(`^${browser.i18n.getMessage('groupTab_label', '.+')}$`); +export const kGROUP_TAB_FROM_PINNED_DEFAULT_TITLE_MATCHER = new RegExp(`^${browser.i18n.getMessage('groupTab_fromPinnedTab_label', '.+')}$`); +export const kSHORTHAND_CUSTOM_URI = /^ext\+ws:([^:?#]+)(?:[:?]([^#]*))?(#.*)?$/; +export const kSHORTHAND_ABOUT_URI = /^about:ws-([^?]+)/; +export const kSHORTHAND_URIS = { + tabbar: browser.runtime.getURL('sidebar/sidebar.html'), + group: kGROUP_TAB_URI, + options: browser.runtime.getURL('options/options.html?independent=true'), + startup: browser.runtime.getURL('resources/startup.html'), + testRunner: browser.runtime.getURL('tests/runner.html'), + 'test-runner': browser.runtime.getURL('tests/runner.html') +}; + +export const kINSERTION_CONTEXT_MOVED = 1; +export const kINSERTION_CONTEXT_SHOWN = 2; +export const kINSERTION_CONTEXT_CREATED = 3; + +export const kIN_CONTENT_PANEL_RENDER_NOWHERE = 0; +export const kIN_CONTENT_PANEL_RENDER_IN_SIDEBAR = 1 << 0; +export const kIN_CONTENT_PANEL_RENDER_IN_CONTENT = 1 << 1; +export const kIN_CONTENT_PANEL_RENDER_IN_ANYWHERE = kIN_CONTENT_PANEL_RENDER_IN_SIDEBAR | kIN_CONTENT_PANEL_RENDER_IN_CONTENT; + +export const kAGGRESSIVE_OPENER_TAB_DETECTION_RULES_WITH_URL = [ + { opener: /^about:addons/, + child: /^https:\/\/addons.mozilla.org\/([^\/]+\/)?[^\/]+\/search\// } +]; + +export const kNOTIFICATION_DEFAULT_ICON = '/resources/64x64.svg#default-bright'; + +// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/storage/sync +// Use 6 * 1024 instead of 8 * 1024 (max of the quota) for safety. +// For example, 6 * 8 = 48KB is the max size of the user style rules. +export const kSYNC_STORAGE_SAFE_QUOTA = 6 * 1024; + +export const kSYNC_DATA_TYPE_TABS = 'tabs'; + +export const IS_BACKGROUND = location.href.startsWith(browser.runtime.getURL('background/background.html')); +export const IS_SIDEBAR = location.href.startsWith(browser.runtime.getURL('sidebar/sidebar.html')); diff --git a/waterfox/browser/components/sidebar/common/contextual-identities.js b/waterfox/browser/components/sidebar/common/contextual-identities.js new file mode 100644 index 000000000000..f46b80759e75 --- /dev/null +++ b/waterfox/browser/components/sidebar/common/contextual-identities.js @@ -0,0 +1,218 @@ +/* +# 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'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; + +import { + configs, + log as internalLogger, + isWindows, +} from './common.js'; +import * as ApiTabs from '/common/api-tabs.js'; + +// eslint-disable-next-line no-unused-vars +function log(...args) { + internalLogger('common/contextual-identities', ...args); +} + +const mContextualIdentities = new Map(); +let mIsObserving = false; + +export function get(id) { + return mContextualIdentities.get(id); +} + +export function getIdFromName(name) { + for (const identity of mContextualIdentities.values()) { + if (identity.name.toLowerCase() == name.toLowerCase()) + return identity.cookieStoreId; + } + return null; +} + +// Respect container type stored by Container Bookmarks +// https://addons.mozilla.org/firefox/addon/container-bookmarks/ +export function getIdFromBookmark(bookmark) { + const containerMatcher = new RegExp(`#${configs.containerRedirectKey}-(.+)$`); + const matchedContainer = bookmark.url.match(containerMatcher); + if (!matchedContainer) + return {}; + + const idPart = matchedContainer[matchedContainer.length-1]; + const url = bookmark.url.replace(containerMatcher, ''); + + // old method + const identity = mContextualIdentities.get(decodeURIComponent(idPart)); + if (identity) { + return { + cookieStoreId: identity.cookieStoreId, + url, + }; + } + + for (const [cookieStoreId, identity] of mContextualIdentities.entries()) { + if (idPart != encodeURIComponent(identity.name.toLowerCase().replace(/\s/g, '-'))) + continue; + return { + cookieStoreId, + url, + }; + } + + return {}; +} + +export function getColorInfo() { + const colors = {}; + const customColors = []; + forEach(identity => { + if (!identity.colorCode) + return; + let colorValue = identity.colorCode; + if (identity.color) { + const customColor = `--contextual-identity-color-${identity.color}`; + customColors.push(`${customColor}: ${identity.colorCode};`); + colorValue = `var(${customColor})`; + } + colors[identity.cookieStoreId] = colorValue; + }); + + return { + colors, + colorDeclarations: customColors.length > 0 ? `:root { ${customColors.join('\n')} }` : '' + }; +} + +export function getCount() { + return mContextualIdentities.size; +} + +export function forEach(callback) { + for (const identity of mContextualIdentities.values()) { + callback(identity); + } +} + +if (browser.contextualIdentities) { // already granted + browser.contextualIdentities.onCreated.addListener(onContextualIdentityCreated); + browser.contextualIdentities.onRemoved.addListener(onContextualIdentityRemoved); + browser.contextualIdentities.onUpdated.addListener(onContextualIdentityUpdated); + mIsObserving = true; +} + +export function startObserve() { + if (!browser.contextualIdentities || + mIsObserving) + return; + mIsObserving = true; + browser.contextualIdentities.onCreated.addListener(onContextualIdentityCreated); + browser.contextualIdentities.onRemoved.addListener(onContextualIdentityRemoved); + browser.contextualIdentities.onUpdated.addListener(onContextualIdentityUpdated); +} + +export function endObserve() { + if (!browser.contextualIdentities || + !mIsObserving) + return; + browser.contextualIdentities.onCreated.removeListener(onContextualIdentityCreated); + browser.contextualIdentities.onRemoved.removeListener(onContextualIdentityRemoved); + browser.contextualIdentities.onUpdated.removeListener(onContextualIdentityUpdated); +} + +export async function init() { + if (!browser.contextualIdentities) + return; + const identities = await browser.contextualIdentities.query({}).catch(ApiTabs.createErrorHandler()); + for (const identity of identities) { + mContextualIdentities.set(identity.cookieStoreId, fixupIcon(identity)); + } +} + +function fixupIcon(identity) { + if (identity.icon && identity.color) + identity.iconUrl = `/resources/icons/contextual-identities/${identity.icon}.svg#${safeColor(identity.color)}`; + return identity; +} + +const mDarkModeMedia = window.matchMedia('(prefers-color-scheme: dark)'); +mDarkModeMedia.addListener(async _event => { + await init(); + forEach(identity => onContextualIdentityUpdated({ contextualIdentity: identity })); +}); + +function safeColor(color) { + switch (color) { + case 'blue': + case 'turquoise': + case 'green': + case 'yellow': + case 'orange': + case 'red': + case 'pink': + case 'purple': + return color; + + case 'toolbar': + default: + return !isWindows() && mDarkModeMedia.matches ? 'toolbar-dark' : 'toolbar-light'; + } +} + +export const onUpdated = new EventListenerManager(); + +function onContextualIdentityCreated(createdInfo) { + if (!mIsObserving) + return; + + const identity = createdInfo.contextualIdentity; + mContextualIdentities.set(identity.cookieStoreId, fixupIcon(identity)); + onUpdated.dispatch(); +} + +function onContextualIdentityRemoved(removedInfo) { + if (!mIsObserving) + return; + + const identity = removedInfo.contextualIdentity; + delete mContextualIdentities.delete(identity.cookieStoreId); + onUpdated.dispatch(); +} + +function onContextualIdentityUpdated(updatedInfo) { + if (!mIsObserving) + return; + + const identity = updatedInfo.contextualIdentity; + mContextualIdentities.set(identity.cookieStoreId, fixupIcon(identity)); + onUpdated.dispatch(); +} + + +export function generateMenuItems({ hasDefault } = {}) { + const fragment = document.createDocumentFragment(); + + if (hasDefault) { + const defaultCotnainerItem = document.createElement('li'); + defaultCotnainerItem.dataset.value = 'firefox-default'; + defaultCotnainerItem.textContent = browser.i18n.getMessage('tabbar_newTabWithContexualIdentity_default'); + fragment.appendChild(defaultCotnainerItem); + + const separator = document.createElement('li'); + separator.classList.add('separator'); + fragment.appendChild(separator); + } + + forEach(identity => { + const item = document.createElement('li'); + item.dataset.value = identity.cookieStoreId; + item.textContent = identity.name; + item.dataset.icon = identity.iconUrl; + fragment.appendChild(item); + }); + + return fragment; +} diff --git a/waterfox/browser/components/sidebar/common/css-selector-parser.js b/waterfox/browser/components/sidebar/common/css-selector-parser.js new file mode 100644 index 000000000000..a855d15366d5 --- /dev/null +++ b/waterfox/browser/components/sidebar/common/css-selector-parser.js @@ -0,0 +1,142 @@ +/* + Original: https://github.com/joakimbeng/split-css-selector + MIT License © Joakim Carlstein +*/ + +'use strict'; + +export function splitSelectors(selectors) { + if (isAtRule(selectors)) + return [selectors]; + + return split(selectors, { + splitter: ',', + appendSplitter: false, + }); +} + +export function splitSelectorParts(selector) { + if (isAtRule(selector)) + return [selector]; + + return split(selector, { + splitters: [ + ' ', '\t', '\n', // https://developer.mozilla.org/en-US/docs/Web/CSS/Descendant_combinator + '>', // https://developer.mozilla.org/en-US/docs/Web/CSS/Child_combinator + '+', // https://developer.mozilla.org/en-US/docs/Web/CSS/Adjacent_sibling_combinator + '~', // https://developer.mozilla.org/en-US/docs/Web/CSS/General_sibling_combinator + // '||', // https://developer.mozilla.org/en-US/docs/Web/CSS/Column_combinator + ], + appendSplitter: true, + }).filter(part => part !== ''); +} + +function split(input, { splitter, splitters, appendSplitter }) { + const splittersSet = splitters && new Set(splitters); + + const splitted = []; + let parens = 0; + let angulars = 0; + let soFar = ''; + let escaped = false; + let singleQuoted = false; + let doubleQuoted = false; + for (const char of input) { + if (escaped) { + soFar += char; + escaped = false; + continue; + } + if (char === '\\' && !escaped) { + soFar += char; + escaped = true; + continue; + } + if (char === "'") { + if (singleQuoted) + singleQuoted = false; + else if (!doubleQuoted) + singleQuoted = true; + } + else if (char === '"') { + if (doubleQuoted) + doubleQuoted = false; + else if (!singleQuoted) + doubleQuoted = true; + } + else if (char === '(') { + parens++; + } + else if (char === ')') { + parens--; + } + else if (char === '[') { + angulars++; + } + else if (char === ']') { + angulars--; + } + else if (splitter && char === splitter) { + if (!parens && + !angulars && + !singleQuoted && + !doubleQuoted) { + splitted.push(soFar.trim()); + soFar = ''; + if (appendSplitter) + soFar += char; + continue; + } + } + else if (splittersSet?.has(char)) { + if (!parens && + !angulars && + !singleQuoted && + !doubleQuoted) { + splitted.push(soFar); + soFar = ''; + if (appendSplitter) + soFar += char; + continue; + } + } + soFar += char; + } + splitted.push(soFar.trim()); + return splitted; +} + +function isAtRule(selector) { + return selector.startsWith('@'); +} + +// https://developer.mozilla.org/en-US/docs/Web/CSS/Pseudo-elements +const PSEUDO_ELEMENTS = ` + :after + ::after + ::backdrop + :before + ::before + ::cue + ::cue-region + :first-letter + ::first-letter + :first-line + ::first-line + ::file-selector-button + ::grammer-error + ::marker + ::part + ::placeholder + ::selection + ::slotted + ::spelling-error + ::target-text +`.trim().split('\n').map(item => item.trim()); +const PSEUDO_ELEMENTS_MATCHER = new RegExp(`(${PSEUDO_ELEMENTS.join('|')})$`, 'i'); + +export function appendPart(baseSelector, appendant) { + if (PSEUDO_ELEMENTS_MATCHER.test(baseSelector)) + return baseSelector.replace(PSEUDO_ELEMENTS_MATCHER, `${appendant}$1`); + return `${baseSelector}${appendant}`; +} diff --git a/waterfox/browser/components/sidebar/common/dialog.js b/waterfox/browser/components/sidebar/common/dialog.js new file mode 100644 index 000000000000..f853118e14fb --- /dev/null +++ b/waterfox/browser/components/sidebar/common/dialog.js @@ -0,0 +1,140 @@ +/* +# 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'; + +import RichConfirm from '/extlib/RichConfirm.js'; + +import { + log as internalLogger, + configs, + isMacOS, + sanitizeForHTMLText, +} from '/common/common.js'; + +import * as ApiTabs from './api-tabs.js'; +import * as Constants from './constants.js'; +import * as Permissions from './permissions.js'; +import * as SidebarConnection from './sidebar-connection.js'; +import * as UserOperationBlocker from './user-operation-blocker.js'; + +import { Tab } from './TreeItem.js'; + +function log(...args) { + internalLogger('common/dialog', ...args); +} + +export async function show(ownerWindow, dialogParams) { + let result; + let unblocked = false; + try { + if (configs.showDialogInSidebar && + SidebarConnection.isOpen(ownerWindow.id)/* && + SidebarConnection.hasFocus(ownerWindow.id)*/) { + UserOperationBlocker.blockIn(ownerWindow.id, { throbber: false }); + result = await browser.runtime.sendMessage({ + type: Constants.kCOMMAND_SHOW_DIALOG, + params: { + ...dialogParams, + onShown: null, + onShownInTab: null, + onShownInPopup: null, + userOperationBlockerParams: { throbber: false }, + }, + windowId: ownerWindow.id + }).catch(ApiTabs.createErrorHandler()); + } + else if (isMacOS() && + ownerWindow.state == 'fullscreen') { + // on macOS, a popup window opened from a fullscreen browser window is always + // opened as a new fullscreen window, thus we need to fallback to a workaround. + log('showDialog: show in a temporary tab in ', ownerWindow.id); + UserOperationBlocker.blockIn(ownerWindow.id, { throbber: false, shade: true }); + const tempTab = await browser.tabs.create({ + windowId: ownerWindow.id, + url: ((await Permissions.isGranted(Permissions.ALL_URLS)) ? null : '/resources/blank.html'), + active: true + }); + await Tab.waitUntilTracked(tempTab.id).then(() => { + Tab.get(tempTab.id).$TST.addState('hidden', { broadcast: true }); + }); + result = await RichConfirm.showInTab(tempTab.id, { + ...dialogParams, + onShown: [ + container => { + const style = container.closest('.rich-confirm-dialog').style; + style.maxWidth = `${Math.floor(window.innerWidth * 0.6)}px`; + style.marginInlineStart = style.marginInlineEnd = 'auto'; + }, + dialogParams.onShownInTab || dialogParams.onShown + ], + onHidden(...params) { + UserOperationBlocker.unblockIn(ownerWindow.id, { throbber: false }); + unblocked = true; + if (typeof dialogParams.onHidden == 'function') + dialogParams.onHidden(...params); + }, + }); + browser.tabs.remove(tempTab.id); + } + else { + log('showDialog: show in a popup window on ', ownerWindow.id); + UserOperationBlocker.blockIn(ownerWindow.id, { throbber: false }); + result = await RichConfirm.showInPopup(ownerWindow.id, { + ...dialogParams, + onShown: dialogParams.onShownInPopup || dialogParams.onShown, + onHidden(...params) { + UserOperationBlocker.unblockIn(ownerWindow.id, { throbber: false }); + unblocked = true; + if (typeof dialogParams.onHidden == 'function') + dialogParams.onHidden(...params); + }, + }); + } + } + catch(_error) { + result = { buttonIndex: -1 }; + } + finally { + if (!unblocked) + UserOperationBlocker.unblockIn(ownerWindow.id, { throbber: false }); + } + return result; +} + +export function tabsToHTMLList(tabs, { maxHeight, maxWidth }) { + const rootLevelOffset = tabs.map(tab => parseInt(tab.$TST.getAttribute(Constants.kLEVEL) || 0)).sort()[0]; + return ( + `
          ` + + tabs.map(tab => `
        • ${sanitizeForHTMLText(tab.title)}
        • `).join('') + + `
        ` + ); +} diff --git a/waterfox/browser/components/sidebar/common/diff.js b/waterfox/browser/components/sidebar/common/diff.js new file mode 100644 index 000000000000..3e63b05e2afc --- /dev/null +++ b/waterfox/browser/components/sidebar/common/diff.js @@ -0,0 +1,909 @@ +/** + * Mainly ported from difflib.py, the standard diff library of Python. + * This code is distributed under the Python Software Foundation License. + * Contributor(s): Sutou Kouhei (porting) + * YUKI "Piro" Hiroshi + * (encoded diff, DOM Updater) + * ------------------------------------------------------------------------ + * Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009 + * Python Software Foundation. + * All rights reserved. + * + * Copyright (c) 2000 BeOpen.com. + * All rights reserved. + * + * Copyright (c) 1995-2001 Corporation for National Research Initiatives. + * All rights reserved. + * + * Copyright (c) 1991-1995 Stichting Mathematisch Centrum. + * All rights reserved. + */ + +export class SequenceMatcher { + constructor(from, to, junkPredicate) { + this.from = from; + this.to = to; + this.junkPredicate = junkPredicate; + this._updateToIndexes(); + } + + longestMatch(fromStart, fromEnd, toStart, toEnd) { + let bestInfo = this._findBestMatchPosition(fromStart, fromEnd, + toStart, toEnd); + const haveJunk = Object.keys(this.junks).length > 0; + if (haveJunk) { + const adjust = this._adjustBestInfoWithJunkPredicate; + const args = [fromStart, fromEnd, toStart, toEnd]; + + bestInfo = adjust.apply(this, [false, bestInfo].concat(args)); + bestInfo = adjust.apply(this, [true, bestInfo].concat(args)); + } + + return bestInfo; + } + + matches() { + if (!this._matches) + this._matches = this._computeMatches(); + return this._matches; + } + + blocks() { + if (!this._blocks) + this._blocks = this._computeBlocks(); + return this._blocks; + } + + operations() { + if (!this._operations) + this._operations = this._computeOperations(); + return this._operations; + } + + groupedOperations(contextSize) { + if (!contextSize) + contextSize = 3; + + let operations = this.operations(); + if (operations.length == 0) + operations = [['equal', 0, 0, 0, 0]]; + operations = this._expandEdgeEqualOperations(operations, contextSize); + + const groupWindow = contextSize * 2; + const groups = []; + let group = []; + for (const operation of operations) { + const tag = operation[0]; + let fromStart = operation[1]; + const fromEnd = operation[2]; + let toStart = operation[3]; + const toEnd = operation[4]; + + if (tag == 'equal' && fromEnd - fromStart > groupWindow) { + group.push([tag, + fromStart, + Math.min(fromEnd, fromStart + contextSize), + toStart, + Math.min(toEnd, toStart + contextSize)]); + groups.push(group); + group = []; + fromStart = Math.max(fromStart, fromEnd - contextSize); + toStart = Math.max(toStart, toEnd - contextSize); + } + group.push([tag, fromStart, fromEnd, toStart, toEnd]); + } + + if (group.length > 0) + groups.push(group); + + return groups; + } + + ratio() { + if (!this._ratio) + this._ratio = this._computeRatio(); + return this._ratio; + } + + _updateToIndexes() { + this.toIndexes = {}; + this.junks = {}; + + for (let i = 0, length = this.to.length; i < length; i++) { + const item = this.to[i]; + + if (!this.toIndexes[item]) + this.toIndexes[item] = []; + this.toIndexes[item].push(i); + } + + if (!this.junkPredicate) + return; + + const toIndexesWithoutJunk = {}; + for (const item in this.toIndexes) { + if (this.junkPredicate(item)) { + this.junks[item] = true; + } else { + toIndexesWithoutJunk[item] = this.toIndexes[item]; + } + } + this.toIndexes = toIndexesWithoutJunk; + } + + _findBestMatchPosition(fromStart, fromEnd, toStart, toEnd) { + let bestFrom = fromStart; + let bestTo = toStart; + let bestSize = 0; + let lastSizes = {}; + let fromIndex; + + for (fromIndex = fromStart; fromIndex <= fromEnd; fromIndex++) { + const sizes = {}; + + const toIndexes = this.toIndexes[this.from[fromIndex]] || []; + for (let i = 0, length = toIndexes.length; i < length; i++) { + const toIndex = toIndexes[i]; + + if (toIndex < toStart) + continue; + if (toIndex > toEnd) + break; + + const size = sizes[toIndex] = (lastSizes[toIndex - 1] || 0) + 1; + if (size > bestSize) { + bestFrom = fromIndex - size + 1; + bestTo = toIndex - size + 1; + bestSize = size; + } + } + lastSizes = sizes; + } + + return [bestFrom, bestTo, bestSize]; + } + + _adjustBestInfoWithJunkPredicate(shouldJunk, bestInfo, + fromStart, fromEnd, + toStart, toEnd) { + let [bestFrom, bestTo, bestSize] = bestInfo; + + while (bestFrom > fromStart && + bestTo > toStart && + (shouldJunk ? + this.junks[this.to[bestTo - 1]] : + !this.junks[this.to[bestTo - 1]]) && + this.from[bestFrom - 1] == this.to[bestTo - 1]) { + bestFrom -= 1; + bestTo -= 1; + bestSize += 1; + } + + while (bestFrom + bestSize < fromEnd && + bestTo + bestSize < toEnd && + (shouldJunk ? + this.junks[this.to[bestTo + bestSize]] : + !this.junks[this.to[bestTo + bestSize]]) && + this.from[bestFrom + bestSize] == this.to[bestTo + bestSize]) { + bestSize += 1; + } + + return [bestFrom, bestTo, bestSize]; + } + + _computeMatches() { + const matches = []; + const queue = [[0, this.from.length, 0, this.to.length]]; + + while (queue.length > 0) { + const target = queue.pop(); + const [fromStart, fromEnd, toStart, toEnd] = target; + + const match = this.longestMatch(fromStart, fromEnd - 1, toStart, toEnd - 1); + const matchFromIndex = match[0]; + const matchToIndex = match[1]; + const size = match[2]; + if (size > 0) { + if (fromStart < matchFromIndex && toStart < matchToIndex) + queue.push([fromStart, matchFromIndex, toStart, matchToIndex]); + + matches.push(match); + if (matchFromIndex + size < fromEnd && matchToIndex + size < toEnd) + queue.push([matchFromIndex + size, fromEnd, + matchToIndex + size, toEnd]); + } + } + + matches.sort((matchInfo1, matchInfo2) => { + const fromIndex1 = matchInfo1[0]; + const fromIndex2 = matchInfo2[0]; + return fromIndex1 - fromIndex2; + }); + return matches; + } + + _computeBlocks() { + const blocks = []; + let currentFromIndex = 0; + let currentToIndex = 0; + let currentSize = 0; + + for (const match of this.matches()) { + const [fromIndex, toIndex, size] = match; + + if (currentFromIndex + currentSize == fromIndex && + currentToIndex + currentSize == toIndex) { + currentSize += size; + } else { + if (currentSize > 0) + blocks.push([currentFromIndex, currentToIndex, currentSize]); + currentFromIndex = fromIndex; + currentToIndex = toIndex; + currentSize = size; + } + } + + if (currentSize > 0) + blocks.push([currentFromIndex, currentToIndex, currentSize]); + + blocks.push([this.from.length, this.to.length, 0]); + return blocks; + } + + _computeOperations() { + let fromIndex = 0; + let toIndex = 0; + const operations = []; + + for (const block of this.blocks()) { + const [matchFromIndex, matchToIndex, size] = block; + + const tag = this._determineTag(fromIndex, toIndex, + matchFromIndex, matchToIndex); + if (tag != 'equal') + operations.push([tag, + fromIndex, matchFromIndex, + toIndex, matchToIndex]); + + fromIndex = matchFromIndex + size; + toIndex = matchToIndex + size; + + if (size > 0) + operations.push(['equal', + matchFromIndex, fromIndex, + matchToIndex, toIndex]); + } + + return operations; + } + + _determineTag(fromIndex, toIndex, + matchFromIndex, matchToIndex) { + if (fromIndex < matchFromIndex && toIndex < matchToIndex) { + return 'replace'; + } else if (fromIndex < matchFromIndex) { + return 'delete'; + } else if (toIndex < matchToIndex) { + return 'insert'; + } else { + return 'equal'; + } + } + + _expandEdgeEqualOperations(operations, contextSize) { + const expandedOperations = []; + + for (let index = 0, length = operations.length; index < length; index++) { + const operation = operations[index]; + const [tag, fromStart, fromEnd, toStart, toEnd] = operation; + + if (tag == 'equal' && index == 0) { + expandedOperations.push([tag, + Math.max(fromStart, fromEnd - contextSize), + fromEnd, + Math.max(toStart, toEnd - contextSize), + toEnd]); + } else if (tag == 'equal' && index == length - 1) { + expandedOperations.push([tag, + fromStart, + Math.min(fromEnd, fromStart + contextSize), + toStart, + Math.min(toEnd, toStart + contextSize), + toEnd]); + } else { + expandedOperations.push(operation); + } + } + + return expandedOperations; + } + + _computeRatio() { + const length = this.from.length + this.to.length; + + if (length == 0) + return 1.0; + + let matches = 0; + for (const block of this.blocks()) { + const size = block[2]; + matches += size; + } + return 2.0 * matches / length; + } + +}; + + +export class ReadableDiffer { + constructor(from, to) { + this.from = from; + this.to = to; + } + + diff() { + let lines = []; + const matcher = new SequenceMatcher(this.from, this.to); + + for (const operation of matcher.operations()) { + const [tag, fromStart, fromEnd, toStart, toEnd] = operation; + let target; + + switch (tag) { + case 'replace': + target = this._diffLines(fromStart, fromEnd, toStart, toEnd); + lines = lines.concat(target); + break; + case 'delete': + target = this.from.slice(fromStart, fromEnd); + lines = lines.concat(this._tagDeleted(target)); + break; + case 'insert': + target = this.to.slice(toStart, toEnd); + lines = lines.concat(this._tagInserted(target)); + break; + case 'equal': + target = this.from.slice(fromStart, fromEnd); + lines = lines.concat(this._tagEqual(target)); + break; + default: + throw 'unknown tag: ' + tag; + break; + } + } + + return lines; + } + + encodedDiff() { + let lines = []; + const matcher = new SequenceMatcher(this.from, this.to); + + for (const operation of matcher.operations()) { + const [tag, fromStart, fromEnd, toStart, toEnd] = operation; + let target; + + switch (tag) { + case 'replace': + target = this._diffLines(fromStart, fromEnd, toStart, toEnd, true); + lines = lines.concat(target); + break; + case 'delete': + target = this.from.slice(fromStart, fromEnd); + lines = lines.concat(this._tagDeleted(target, true)); + break; + case 'insert': + target = this.to.slice(toStart, toEnd); + lines = lines.concat(this._tagInserted(target, true)); + break; + case 'equal': + target = this.from.slice(fromStart, fromEnd); + lines = lines.concat(this._tagEqual(target, true)); + break; + default: + throw new Error(`unknown tag: ${tag}`); + break; + } + } + + const blocks = []; + let lastBlock = ''; + let lastLineType = ''; + for (const line of lines) { + const lineType = line.match(/^' : '' )); + lastBlock = ``; + lastLineType = lineType; + } + lastBlock += line; + } + if (lastBlock) + blocks.push(`${lastBlock}`); + + return blocks.join(''); + } + + _tagLine(mark, contents) { + return contents.map(content => `${mark} ${content}`); + } + + _encodedTagLine(encodedClass, contents) { + return contents.map(content => `${this._escapeForEncoded(content)}`); + } + + _escapeForEncoded(string) { + return string + .replace(/&/g, '&') + .replace(//g, '>'); + } + + _tagDeleted(contents, encoded) { + return encoded ? + this._encodedTagLine('deleted', contents) : + this._tagLine('-', contents); + } + + _tagInserted(contents, encoded) { + return encoded ? + this._encodedTagLine('inserted', contents) : + this._tagLine('+', contents); + } + + _tagEqual(contents, encoded) { + return encoded ? + this._encodedTagLine('equal', contents) : + this._tagLine(' ', contents); + } + + _tagDifference(contents, encoded) { + return encoded ? + this._encodedTagLine('difference', contents) : + this._tagLine('?', contents); + } + + _findDiffLineInfo(fromStart, fromEnd, toStart, toEnd) { + let bestRatio = 0.74; + let fromEqualIndex, toEqualIndex; + let fromBestIndex, toBestIndex; + + for (let toIndex = toStart; toIndex < toEnd; toIndex++) { + for (let fromIndex = fromStart; fromIndex < fromEnd; fromIndex++) { + if (this.from[fromIndex] == this.to[toIndex]) { + if (fromEqualIndex === undefined) + fromEqualIndex = fromIndex; + if (toEqualIndex === undefined) + toEqualIndex = toIndex; + continue; + } + + const matcher = new SequenceMatcher(this.from[fromIndex], + this.to[toIndex], + this._isSpaceCharacter); + if (matcher.ratio() > bestRatio) { + bestRatio = matcher.ratio(); + fromBestIndex = fromIndex; + toBestIndex = toIndex; + } + } + } + + return [bestRatio, + fromEqualIndex, toEqualIndex, + fromBestIndex, toBestIndex]; + } + + _diffLines(fromStart, fromEnd, toStart, toEnd, encoded) { + const cutOff = 0.75; + const info = this._findDiffLineInfo(fromStart, fromEnd, toStart, toEnd); + let bestRatio = info[0]; + const fromEqualIndex = info[1]; + const toEqualIndex = info[2]; + let fromBestIndex = info[3]; + let toBestIndex = info[4]; + + if (bestRatio < cutOff) { + if (fromEqualIndex === undefined) { + const taggedFrom = this._tagDeleted(this.from.slice(fromStart, fromEnd), encoded); + const taggedTo = this._tagInserted(this.to.slice(toStart, toEnd), encoded); + if (toEnd - toStart < fromEnd - fromStart) + return taggedTo.concat(taggedFrom); + else + return taggedFrom.concat(taggedTo); + } + + fromBestIndex = fromEqualIndex; + toBestIndex = toEqualIndex; + bestRatio = 1.0; + } + + return [].concat( + this.__diffLines(fromStart, fromBestIndex, + toStart, toBestIndex, + encoded), + (encoded ? + this._diffLineEncoded(this.from[fromBestIndex], + this.to[toBestIndex]) : + this._diffLine(this.from[fromBestIndex], + this.to[toBestIndex]) + ), + this.__diffLines(fromBestIndex + 1, fromEnd, + toBestIndex + 1, toEnd, + encoded) + ); + } + + __diffLines(fromStart, fromEnd, toStart, toEnd, encoded) { + if (fromStart < fromEnd) { + if (toStart < toEnd) { + return this._diffLines(fromStart, fromEnd, toStart, toEnd, encoded); + } else { + return this._tagDeleted(this.from.slice(fromStart, fromEnd), encoded); + } + } else { + return this._tagInserted(this.to.slice(toStart, toEnd), encoded); + } + } + + _diffLineEncoded(fromLine, toLine) { + const fromChars = fromLine.split(''); + const toChars = toLine.split(''); + const matcher = new SequenceMatcher(fromLine, toLine, + this._isSpaceCharacter); + const phrases = []; + for (const operation of matcher.operations()) { + const [tag, fromStart, fromEnd, toStart, toEnd] = operation; + const fromPhrase = fromChars.slice(fromStart, fromEnd).join(''); + const toPhrase = toChars.slice(toStart, toEnd).join(''); + switch (tag) { + case 'replace': + case 'delete': + case 'insert': + case 'equal': + phrases.push({ tag : tag, + from : fromPhrase, + encodedFrom : this._escapeForEncoded(fromPhrase), + to : toPhrase, + encodedTo : this._escapeForEncoded(toPhrase), }); + break; + default: + throw new Error(`unknown tag: ${tag}`); + } + } + + const encodedPhrases = []; + let current; + let replaced = 0; + let inserted = 0; + let deleted = 0; + for (let i = 0, maxi = phrases.length; i < maxi; i++) + { + current = phrases[i]; + switch (current.tag) { + case 'replace': + encodedPhrases.push(''); + encodedPhrases.push(this._encodedTagPhrase('deleted', current.encodedFrom)); + encodedPhrases.push(this._encodedTagPhrase('inserted', current.encodedTo)); + encodedPhrases.push(''); + replaced++; + break; + case 'delete': + encodedPhrases.push(this._encodedTagPhrase('deleted', current.encodedFrom)); + deleted++; + break; + case 'insert': + encodedPhrases.push(this._encodedTagPhrase('inserted', current.encodedTo)); + inserted++; + break; + case 'equal': + // \95ύX\93_\82̊Ԃɋ\B2\82܂ꂽ1\95\B6\8E\9A\82\BE\82\AF\82̖\B3\95ύX\95\94\95\AA\82\BE\82\AF\82͓\C1\95ʈ\B5\82\A2 + if ( + current.from.length == 1 && + (i > 0 && phrases[i-1].tag != 'equal') && + (i < maxi-1 && phrases[i+1].tag != 'equal') + ) { + encodedPhrases.push(''); + encodedPhrases.push(this._encodedTagPhrase('duplicated', current.encodedFrom)); + encodedPhrases.push(this._encodedTagPhrase('duplicated', current.encodedTo)); + encodedPhrases.push(''); + } + else { + encodedPhrases.push(current.encodedFrom); + } + break; + } + } + + const extraClass = (replaced || (deleted && inserted)) ? + ' includes-both-modification' : + '' ; + + return [ + `${encodedPhrases.join('')}` + ]; + } + + _encodedTagPhrase(encodedClass, content) { + return `${content}`; + } + + _diffLine(fromLine, toLine) { + let fromTags = ''; + let toTags = ''; + const matcher = new SequenceMatcher(fromLine, toLine, + this._isSpaceCharacter); + + for (const operation of matcher.operations()) { + const [tag, fromStart, fromEnd, toStart, toEnd] = operation; + + const fromLength = fromEnd - fromStart; + const toLength = toEnd - toStart; + switch (tag) { + case 'replace': + fromTags += this._repeat('^', fromLength); + toTags += this._repeat('^', toLength); + break; + case 'delete': + fromTags += this._repeat('-', fromLength); + break; + case 'insert': + toTags += this._repeat('+', toLength); + break; + case 'equal': + fromTags += this._repeat(' ', fromLength); + toTags += this._repeat(' ', toLength); + break; + default: + throw new Error(`unknown tag: ${tag}`); + break; + } + } + + return this._formatDiffPoint(fromLine, toLine, fromTags, toTags); + } + + _formatDiffPoint(fromLine, toLine, fromTags, toTags) { + let common; + let result; + + common = Math.min(this._nLeadingCharacters(fromLine, '\t'), + this._nLeadingCharacters(toLine, '\t')); + common = Math.min(common, + this._nLeadingCharacters(fromTags.slice(0, common), + ' ')); + fromTags = fromTags.slice(common).replace(/\s*$/, ''); + toTags = toTags.slice(common).replace(/\s*$/, ''); + + result = this._tagDeleted([fromLine]); + if (fromTags.length > 0) { + fromTags = this._repeat('\t', common) + fromTags; + result = result.concat(this._tagDifference([fromTags])); + } + result = result.concat(this._tagInserted([toLine])); + if (toTags.length > 0) { + toTags = this._repeat('\t', common) + toTags; + result = result.concat(this._tagDifference([toTags])); + } + + return result; + } + + _nLeadingCharacters(string, character) { + let n = 0; + while (string[n] == character) { + n++; + } + return n; + } + + _isSpaceCharacter(character) { + return character == ' ' || character == '\t'; + } + + _repeat(string, n) { + let result = ''; + for (; n > 0; n--) { + result += string; + } + return result; + } + +}; + + +export const Diff = { + + readable(from, to, encoded) { + const differ = new ReadableDiffer(this._splitWithLine(from), this._splitWithLine(to)); + return encoded ? + differ.encodedDiff() : + differ.diff(encoded).join('\n') ; + }, + + foldedReadable(from, to, encoded) { + const differ = new ReadableDiffer(this._splitWithLine(this._fold(from)), + this._splitWithLine(this._fold(to))); + return encoded ? + differ.encodedDiff() : + differ.diff(encoded).join('\n') ; + }, + + isInterested(diff) { + if (!diff) + return false; + + if (diff.length == 0) + return false; + + if (!diff.match(/^[-+]/mg)) + return false; + + if (diff.match(/^[ ?]/mg)) + return true; + + if (diff.match(/(?:.*\n){2,}/g)) + return true; + + if (this.needFold(diff)) + return true; + + return false; + }, + + needFold(diff) { + if (!diff) + return false; + + if (diff.match(/^[-+].{79}/mg)) + return true; + + return false; + }, + + _splitWithLine(string) { + string = String(string); + return string.length == 0 ? [] : string.split(/\r?\n/); + }, + + _fold(string) { + string = String(string); + const foldedLines = string.split('\n').map(line => line.replace(/(.{78})/g, '$1\n')); + return foldedLines.join('\n'); + } + +}; + + +export const DOMUpdater = { + /** + * method + * @param before {Node} - the node to be updated, e.g. Element + * @param after {Node} - the node describing updated state, + * e.g. DocumentFragment + * @return count {number} - the count of appied changes + */ + update(before, after, counter = { count: 0 }) { + if (before.nodeValue !== null || + after.nodeValue !== null) { + if (before.nodeValue != after.nodeValue) { + //console.log('node value: ', after.nodeValue); + before.nodeValue = after.nodeValue; + counter.count++; + } + return counter.count; + } + + const beforeNodes = Array.from(before.childNodes, this._getDiffableNodeString); + const afterNodes = Array.from(after.childNodes, this._getDiffableNodeString); + const nodeOerations = (new SequenceMatcher(beforeNodes, afterNodes)).operations(); + // Update from back to front for safety! + for (const operation of nodeOerations.reverse()) { + const [tag, fromStart, fromEnd, toStart, toEnd] = operation; + switch (tag) { + case 'equal': + for (let i = 0, maxi = fromEnd - fromStart; i < maxi; i++) { + this.update( + before.childNodes[fromStart + i], + after.childNodes[toStart + i], + counter + ); + } + break; + case 'delete': + for (let i = fromEnd - 1; i >= fromStart; i--) { + //console.log('delete: delete node: ', i, before.childNodes[i]); + before.removeChild(before.childNodes[i]); + counter.count++; + } + break; + case 'insert': { + const reference = before.childNodes[fromStart] || null; + for (let i = toStart; i < toEnd; i++) { + if (!after.childNodes[i]) + continue; + //console.console.log('insert: insert node: ', i, after.childNodes[i]); + before.insertBefore(after.childNodes[i].cloneNode(true), reference); + counter.count++; + } + }; break; + case 'replace': { + for (let i = fromEnd - 1; i >= fromStart; i--) { + //console.log('replace: delete node: ', i, before.childNodes[i]); + before.removeChild(before.childNodes[i]); + counter.count++; + } + const reference = before.childNodes[fromStart] || null; + for (let i = toStart; i < toEnd; i++) { + if (!after.childNodes[i]) + continue; + //console.log('replace: insert node: ', i, after.childNodes[i]); + before.insertBefore(after.childNodes[i].cloneNode(true), reference); + counter.count++; + } + }; break; + } + } + + if (before.nodeType == before.ELEMENT_NODE && + after.nodeType == after.ELEMENT_NODE) { + const beforeAttrs = Array.from(before.attributes, attr => `${attr.name}:${attr.value}`). sort(); + const afterAttrs = Array.from(after.attributes, attr => `${attr.name}:${attr.value}`). sort(); + const attrOerations = (new SequenceMatcher(beforeAttrs, afterAttrs)).operations(); + for (const operation of attrOerations) { + const [tag, fromStart, fromEnd, toStart, toEnd] = operation; + switch (tag) { + case 'equal': + break; + case 'delete': + for (let i = fromStart; i < fromEnd; i++) { + const name = beforeAttrs[i].split(':')[0]; + //console.log('delete: delete attr: ', name); + before.removeAttribute(name); + counter.count++; + } + break; + case 'insert': + for (let i = toStart; i < toEnd; i++) { + const attr = afterAttrs[i].split(':'); + const name = attr[0]; + const value = attr.slice(1).join(':'); + //console.log('insert: set attr: ', name, value); + before.setAttribute(name, value); + counter.count++; + } + break; + case 'replace': + const insertedAttrs = new Set(); + for (let i = toStart; i < toEnd; i++) { + const attr = afterAttrs[i].split(':'); + const name = attr[0]; + const value = attr.slice(1).join(':'); + //console.log('replace: set attr: ', name, value); + before.setAttribute(name, value); + insertedAttrs.add(name); + counter.count++; + } + for (let i = fromStart; i < fromEnd; i++) { + const name = beforeAttrs[i].split(':')[0]; + if (insertedAttrs.has(name)) + continue; + //console.log('replace: delete attr: ', name); + before.removeAttribute(name); + counter.count++; + } + break; + } + } + } + return counter.count; + }, + + _getDiffableNodeString(node) { + if (node.nodeType == node.ELEMENT_NODE) + return `element:${node.tagName}#${node.id}#${node.getAttribute('anonid')}`; + else + return `node:${node.nodeType}`; + } + +}; diff --git a/waterfox/browser/components/sidebar/common/handle-accel-key.js b/waterfox/browser/components/sidebar/common/handle-accel-key.js new file mode 100644 index 000000000000..ca1188530e8f --- /dev/null +++ b/waterfox/browser/components/sidebar/common/handle-accel-key.js @@ -0,0 +1,103 @@ +/* +# 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'; + +(function() { + if (window.handleAccelKeyLoaded) + return; + + function stringifySoloModifier(event) { + switch (event.key) { + case 'Alt': + return ( + !event.ctrlKey && + !event.metaKey + ) ? 'alt' : null; + + case 'Control': + return ( + !event.altKey && + !event.metaKey + ) ? 'control' : null; + + case 'Meta': + return ( + !event.altKey && + !event.ctrlKey + ) ? 'meta' : null; + + default: + return null; + } + } + + function stringifyModifier(event) { + if (event.altKey && + !event.ctrlKey && + !event.metaKey) + return 'alt'; + + if (!event.altKey && + event.ctrlKey && + !event.metaKey) + return 'control'; + + if (!event.altKey && + !event.ctrlKey && + event.metaKey) + return 'meta'; + + return null; + } + + function stringifyUnshiftedSoloModifier(event) { + if (event.key != 'Shift') + return null; + return stringifyModifier(event); + } + + function stringifyMayTabSwitchModifier(event) { + if (!/^(Tab|Shift|PageUp|PageDown)$/.test(event.key)) + return null; + return stringifyModifier(event); + } + + function onKeyDown(event) { + const modifier = stringifySoloModifier(event); + const mayTabSwitchModifier = stringifyMayTabSwitchModifier(event); + if (modifier || + mayTabSwitchModifier) + browser.runtime.sendMessage({ + type: 'ws:notify-may-start-tab-switch', + modifier: modifier || mayTabSwitchModifier + }); + } + + function onKeyUp(event) { + const modifier = stringifySoloModifier(event); + const unshiftedModifier = stringifyUnshiftedSoloModifier(event); + const mayTabSwitchModifier = stringifyMayTabSwitchModifier(event); + if (modifier || + (!unshiftedModifier && + !mayTabSwitchModifier)) + browser.runtime.sendMessage({ + type: 'ws:notify-may-end-tab-switch', + modifier: modifier || stringifyModifier(event) + }); + } + + function init() { + window.handleAccelKeyLoaded = true; + window.addEventListener('keydown', onKeyDown, { capture: true }); + window.addEventListener('keyup', onKeyUp, { capture: true }); + window.addEventListener('pagehide', () => { + window.addEventListener('keydown', onKeyDown, { capture: true }); + window.addEventListener('keyup', onKeyUp, { capture: true }); + window.addEventListener('pageshow', init, { once: true }); + }, { once: true }); + } + init(); +})(); diff --git a/waterfox/browser/components/sidebar/common/permissions.js b/waterfox/browser/components/sidebar/common/permissions.js new file mode 100644 index 000000000000..98c6a1ad4983 --- /dev/null +++ b/waterfox/browser/components/sidebar/common/permissions.js @@ -0,0 +1,362 @@ +/* +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ +'use strict'; + +import { + log as internalLogger, + notify, + configs +} from './common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from './constants.js'; + +function log(...args) { + internalLogger('common/permissions', ...args); +} + +export const ALL_URLS = { origins: [''] }; +export const BOOKMARKS = { permissions: ['bookmarks'] }; +export const CLIPBOARD_READ = { permissions: ['clipboardRead'] }; +export const TAB_HIDE = { permissions: ['tabHide'] }; + +const checkboxesForPermission = new Map(); + +export function clearRequest() { + configs.requestingPermissions = null; +} + + +const cachedGranted = new Map(); + +export async function isGranted(permissions) { + try { + const granted = await browser.permissions.contains(permissions).catch(ApiTabs.createErrorHandler()); + cachedGranted.set(JSON.stringify(permissions), granted); + return granted; + } + catch(error) { + console.error(error); + return Promise.reject(new Error('unsupported permission')); + } +} + +export function isGrantedSync(permissions) { + return cachedGranted.get(JSON.stringify(permissions)); +} + +// cache last state +for (const permissions of [ALL_URLS, BOOKMARKS, CLIPBOARD_READ, TAB_HIDE]) { + isGranted(permissions); +} + + +const CUSTOM_PANEL_AVAILABLE_URLS_MATCHER = new RegExp(`^((https?|data):|moz-extension://${location.host}/)`); + +export async function canInjectScriptToTab(tab) { + if (!tab || + !CUSTOM_PANEL_AVAILABLE_URLS_MATCHER.test(tab.url)) + return false; + + return isGranted(ALL_URLS); +} + +export function canInjectScriptToTabSync(tab) { + if (!tab || + !CUSTOM_PANEL_AVAILABLE_URLS_MATCHER.test(tab.url)) + return false; + + return isGrantedSync(ALL_URLS); +} + + +const mRequests = new Map(); + +function destroyRequest(request) { + const permissions = JSON.stringify(request.permissions); + const requests = mRequests.get(permissions); + if (requests) + requests.delete(request); + + const onChanged = request.resolve; + const checkbox = request.url; + + request.permissions = undefined; + request.onChanged = undefined; + request.checkbox = undefined; + + return { onChanged, checkbox }; +} + +browser.runtime.onMessage.addListener((message, _sender) => { + if (!message || + !message.type || + message.type != Constants.kCOMMAND_NOTIFY_PERMISSIONS_GRANTED) + return; + + const permissions = JSON.stringify(message.permissions); + + isGranted(message.permissions); // to cache latest state + + const requests = mRequests.get(permissions); + if (!requests) + return; + + mRequests.delete(permissions); + + for (const request of requests) { + const { onChanged, checkbox } = destroyRequest(request); + const checked =onChanged ? + onChanged(true) : + undefined; + checkbox.checked = checked !== undefined ? !!checked : true; + } +}); + +/* +// These events are not available yet on Firefox... +browser.permissions.onAdded.addListener(addedPermissions => { + const permissions = JSON.stringify(addedPermissions.permissions); + const requests = mRequests.get(permissions); + if (!requests) + return; + + mRequests.delete(permissions); + + for (const request of requests) { + const { checkbox } = destroyRequest(request); + checkbox.checked = true; + } +}); +browser.permissions.onRemoved.addListener(removedPermissions => { + const permissions = JSON.stringify(addedPermissions.permissions); + const requests = mRequests.get(permissions); + if (!requests) + return; + + mRequests.delete(permissions); + + for (const request of requests) { + const { checkbox } = destroyRequest(request); + checkbox.checked = false; + } +}); +*/ + +export function bindToCheckbox(permissions, checkbox, options = {}) { + const checkboxes = checkboxesForPermission.get(permissions) || []; + checkboxes.push(checkbox); + checkboxesForPermission.set(permissions, checkboxes); + + isGranted(permissions) + .then(granted => { + const checked = options.onInitialized ? + options.onInitialized(granted) : + checkbox.dataset.relatedConfigKey ? + configs[checkbox.dataset.relatedConfigKey] : + undefined; + checkbox.checked = checked !== undefined ? !!checked : granted; + }) + .catch(_error => { + checkbox.setAttribute('readonly', true); + checkbox.setAttribute('disabled', true); + const label = checkbox.closest('label') || document.querySelector(`label[for=${checkbox.id}]`); + if (label) + label.setAttribute('disabled', true); + }); + + checkbox.addEventListener('change', _event => { + checkbox.requestPermissions() + }); + + const key = JSON.stringify(permissions); + const requests = mRequests.get(key) || new Set(); + const request = { + permissions, + onChanged: options.onChanged, + checkbox, + }; + requests.add(request); + mRequests.set(key, requests); + + checkbox.requestPermissions = async () => { + log('permission requested: ', permissions); + const checkboxes = checkboxesForPermission.get(permissions); + try { + log('checkboxes: ', checkboxes); + log('checkbox.checked: ', checkbox.checked); + if (!checkbox.checked) { + if (checkbox.dataset.relatedConfigKey) + configs[checkbox.dataset.relatedConfigKey] = false; + if (options.onChanged) + options.onChanged(false); + const canRevoke = Array.from(checkboxes, checkbox => checkbox.dataset.relatedConfigKey ? configs[checkbox.dataset.relatedConfigKey] : null).filter(state => state !== null).every(state => !state); + log('canRevoke: ', canRevoke); + if (!canRevoke) + return; + log('revoking the permission'); + await browser.permissions.remove(permissions).catch(ApiTabs.createErrorSuppressor()); + for (const otherCheckbox of checkboxes) { + if (otherCheckbox != checkbox && + otherCheckbox.dataset.relatedConfigKey) + continue; + otherCheckbox.checked = false; + } + return; + } + + for (const otherCheckbox of checkboxes) { + if (otherCheckbox != checkbox && + otherCheckbox.dataset.relatedConfigKey) + continue; + otherCheckbox.checked = false; + } + + if (configs.requestingPermissionsNatively) + return; + + log('requesting the permission'); + configs.requestingPermissionsNatively = permissions; + let granted = await browser.permissions.request(permissions).catch(ApiTabs.createErrorHandler()); + configs.requestingPermissionsNatively = null; + + log('granted: ', granted); + if (granted === undefined) { + granted = await isGranted(permissions); + log('granted (retry): ', granted); + } + else if (!granted) { + log('not granted: cacneled'); + return; + } + + if (granted) { + if (checkbox.dataset.relatedConfigKey) + configs[checkbox.dataset.relatedConfigKey] = true; + const configValue = checkbox.dataset.relatedConfigKey ? true : null; + const onChangedResult = options.onChanged && options.onChanged(true); + const checked = configValue !== null ? configValue : + options.onChanged ? + onChangedResult : + undefined; + log('update checkboxes with checked state ', checked); + for (const otherCheckbox of checkboxes) { + if (otherCheckbox != checkbox && + otherCheckbox.dataset.relatedConfigKey) + continue; + otherCheckbox.checked = checked !== undefined ? !!checked : true; + } + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_PERMISSIONS_GRANTED, + permissions + }).catch(_error => {}); + log('finish'); + return; + } + + log('fallback to the failsafe method'); + configs.requestingPermissions = permissions; + browser.browserAction.setBadgeText({ text: '!' }); + browser.browserAction.setPopup({ popup: '' }); + + notify({ + title: browser.i18n.getMessage('config_requestPermissions_fallbackToToolbarButton_title'), + message: browser.i18n.getMessage('config_requestPermissions_fallbackToToolbarButton_message'), + icon: 'resources/24x24.svg#default' + }); + return; + } + catch(error) { + console.log(error); + } + for (const checkbox of checkboxes) { + checkbox.checked = false; + } + }; +} + +export function bindToClickable(permissions, node, { onChanged } = {}) { + node.addEventListener('click', _event => { + node.requestPermissions() + }); + + if (node.requestPermissions) + return; + + node.requestPermissions = async () => { + try { + const checkboxes = checkboxesForPermission.get(permissions); + if (configs.requestingPermissionsNatively || + checkboxes.every(checkbox => checkbox.checked)) + return; + + configs.requestingPermissionsNatively = permissions; + // We need to call this without delay to avoid "permissions.request may only be called from a user input handler" error. + let granted = await browser.permissions.request(permissions).catch(ApiTabs.createErrorHandler()); + configs.requestingPermissionsNatively = null; + + if (granted === undefined) + granted = await isGranted(permissions); + else if (!granted) + return; + + if (granted) { + for (const checkbox of checkboxes) { + checkbox.checked = true; + } + if (onChanged) + onChanged(true); + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_PERMISSIONS_GRANTED, + permissions + }).catch(_error => {}); + return; + } + + configs.requestingPermissions = permissions; + browser.browserAction.setBadgeText({ text: '!' }); + browser.browserAction.setPopup({ popup: '' }); + + notify({ + title: browser.i18n.getMessage('config_requestPermissions_fallbackToToolbarButton_title'), + message: browser.i18n.getMessage('config_requestPermissions_fallbackToToolbarButton_message'), + icon: 'resources/24x24.svg#default' + }); + return; + } + catch(error) { + console.log(error); + } + }; +} + +export function requestPostProcess() { + if (!configs.requestingPermissions) + return false; + + const permissions = configs.requestingPermissions; + configs.requestingPermissions = null; + configs.requestingPermissionsNatively = permissions; + + browser.browserAction.setBadgeText({ text: '' }); + browser.permissions.request(permissions) + .then(granted => { + log('permission requested: ', permissions, granted); + if (granted) + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_PERMISSIONS_GRANTED, + permissions + }).catch(_error => {}); + }) + .catch(ApiTabs.createErrorSuppressor()) + .finally(() => { + configs.requestingPermissionsNatively = null; + }); + return true; +} + +configs.$loaded.then(() => { + configs.requestingPermissionsNatively = null; +}); diff --git a/waterfox/browser/components/sidebar/common/retrieve-url.js b/waterfox/browser/components/sidebar/common/retrieve-url.js new file mode 100644 index 000000000000..a73c730f3b8a --- /dev/null +++ b/waterfox/browser/components/sidebar/common/retrieve-url.js @@ -0,0 +1,197 @@ +/* +# 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'; + +import { + log as internalLogger, +} from './common.js'; +import * as Permissions from './permissions.js'; + +function log(...args) { + internalLogger('common/retrieve-url', ...args); +} + +export const kTYPE_PLAIN_TEXT = 'text/plain'; +export const kTYPE_X_MOZ_URL = 'text/x-moz-url'; +export const kTYPE_URI_LIST = 'text/uri-list'; +export const kTYPE_MOZ_TEXT_INTERNAL = 'text/x-moz-text-internal'; +const kBOOKMARK_FOLDER = 'x-moz-place:'; + +const ACCEPTABLE_DATA_TYPES = [ + kTYPE_URI_LIST, + kTYPE_X_MOZ_URL, + kTYPE_MOZ_TEXT_INTERNAL, + kTYPE_PLAIN_TEXT, +]; + +let mFileURLResolver = null; + +export async function registerFileURLResolver(resolver) { + mFileURLResolver = resolver; +} + +export async function fromDragEvent(event) { + log('fromDragEvent ', event); + const dt = event.dataTransfer; + const urls = []; + if (dt.files.length > 0) { + for (const file of dt.files) { + if (typeof mFileURLResolver == 'function') { + urls.push(await mFileURLResolver(file)); + } + else { + // Created object URLs need to be revoked by tryRevokeObjectURL() + // in common/common.js, after they are loaded. + urls.push(URL.createObjectURL(file)); + } + } + } + else { + for (const type of ACCEPTABLE_DATA_TYPES) { + const urlData = dt.getData(type); + if (urlData) + urls.push(...fromData(urlData, type)); + if (urls.length) + break; + } + for (const type of dt.types) { + if (!/^application\/x-ws-drag-data;(.+)$/.test(type)) + continue; + const params = RegExp.$1; + const providerId = /provider=([^;&]+)/.test(params) && RegExp.$1; + const dataId = /id=([^;&]+)/.test(params) && RegExp.$1; + try { + const dragData = await browser.runtime.sendMessage(providerId, { + type: 'get-drag-data', + id: dataId + }); + if (!dragData || typeof dragData != 'object') + break; + for (const type of ACCEPTABLE_DATA_TYPES) { + const urlData = dragData[type]; + if (urlData) + urls.push(...fromData(urlData, type)); + if (urls.length) + break; + } + } + catch(_error) { + } + } + } + log(' => retrieved: ', urls); + return sanitizeURLs(urls); +} + +let mSelectionClipboardProvider = null; + +/* provider should have two methods: + isAvailable(): returns boolean which indicates the selection clipboard is available or not. + getTextData(): returns a string from the selection clipboard. +*/ +export function registerSelectionClipboardProvider(provider) { + mSelectionClipboardProvider = provider; +} + +export async function fromClipboard({ selection } = {}) { + const urls = []; + + if (selection && mSelectionClipboardProvider) { + if (await mSelectionClipboardProvider.isAvailable()) { + const maybeUrlString = await mSelectionClipboardProvider.getTextData(); + if (maybeUrlString) + urls.push(...fromData(maybeUrlString, kTYPE_PLAIN_TEXT)); + return sanitizeURLs(urls); + } + } + + if (!(await Permissions.isGranted(Permissions.CLIPBOARD_READ)) || + typeof navigator.clipboard.read != 'function') + return urls; + + const clipboardContents = await navigator.clipboard.read(); + log('fromClipboard ', clipboardContents); + + for (const item of clipboardContents) { + for (const type of item.types) { + const maybeUrlBlob = await item.getType(type); + const maybeUrlString = await maybeUrlBlob.text(); + if (maybeUrlString) + urls.push(...fromData(maybeUrlString, type)); + if (urls.length) + break; + } + } + log(' => retrieved: ', urls); + return sanitizeURLs(urls); +} + +function sanitizeURLs(urls) { + const filteredUrls = urls.filter(url => + url && + url.length && + url.indexOf(kBOOKMARK_FOLDER) == 0 || + !/^\s*(javascript|data):/.test(url) + ); + log('sanitizeURLs filtered: ', filteredUrls); + + const fixedUrls = filteredUrls.map(fixupURIFromText); + log('sanitizeURLs fixed: ', fixedUrls); + + return fixedUrls; +} + +function fromData(data, type) { + log('fromData: ', type, data); + switch (type) { + case kTYPE_URI_LIST: + return data + .replace(/\r/g, '\n') + .replace(/\n\n+/g, '\n') + .split('\n') + .filter(line => { + return line.charAt(0) != '#'; + }); + + case kTYPE_X_MOZ_URL: + return data + .trim() + .replace(/\r/g, '\n') + .replace(/\n\n+/g, '\n') + .split('\n') + .filter((_line, index) => { + return index % 2 == 0; + }); + + case kTYPE_MOZ_TEXT_INTERNAL: + return data + .replace(/\r/g, '\n') + .replace(/\n\n+/g, '\n') + .trim() + .split('\n'); + + case kTYPE_PLAIN_TEXT: + return data + .replace(/\r/g, '\n') + .replace(/\n\n+/g, '\n') + .trim() + .split('\n') + .map(line => { + return /^\w+:\/\/.+/.test(line) ? line : `ext+ws:search:${line}`; + }); + } + return []; +} + +function fixupURIFromText(maybeURI) { + if (/^(ext\+)?\w+:/.test(maybeURI)) + return maybeURI; + + if (/^([^\.\s]+\.)+[^\.\s]{2}/.test(maybeURI)) + return `http://${maybeURI}`; + + return maybeURI; +} diff --git a/waterfox/browser/components/sidebar/common/sidebar-connection.js b/waterfox/browser/components/sidebar/common/sidebar-connection.js new file mode 100644 index 000000000000..80b8adfa091e --- /dev/null +++ b/waterfox/browser/components/sidebar/common/sidebar-connection.js @@ -0,0 +1,305 @@ +/* +# 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'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; + +import { + log as internalLogger, + mapAndFilterUniq, + configs, + wait, +} from './common.js'; +import * as Constants from './constants.js'; +import * as TabsStore from './tabs-store.js'; + +function log(...args) { + internalLogger('common/sidebar-connection', ...args); +} + +export const onMessage = new EventListenerManager(); +export const onConnected = new EventListenerManager(); +export const onDisconnected = new EventListenerManager(); + +const mConnections = new Map(); +const mReceivers = new Map(); +const mFocusState = new Map(); +let mIsListening = false; + +export function isInitialized() { + return mIsListening; +} + +export function isSidebarOpen(windowId) { + if (!mIsListening || + Constants.IS_SIDEBAR) + return false; + + // for automated tests + if (configs.sidebarVirtuallyOpenedWindows.length > 0 && + configs.sidebarVirtuallyOpenedWindows.includes(windowId)) + return true; + if (configs.sidebarVirtuallyClosedWindows.length > 0 && + configs.sidebarVirtuallyClosedWindows.includes(windowId)) + return false; + + if (windowId in configs.sidebarWidthInWindow && + configs.sidebarWidthInWindow[windowId] == 0) + return false; + + const connections = mConnections.get(windowId); + if (!connections) + return false; + for (const connection of connections) { + if (connection.type == 'sidebar') + return true; + } + return false; +} + +export function isOpen(windowId) { + if (!mIsListening || + Constants.IS_SIDEBAR) + return false; + + // for automated tests + if (configs.sidebarVirtuallyOpenedWindows.length > 0 && + configs.sidebarVirtuallyOpenedWindows.includes(windowId)) + return true; + if (configs.sidebarVirtuallyClosedWindows.length > 0 && + configs.sidebarVirtuallyClosedWindows.includes(windowId)) + return false; + + if (windowId in configs.sidebarWidthInWindow && + configs.sidebarWidthInWindow[windowId] == 0) + return false; + + const connections = mConnections.get(windowId); + return connections && connections.size > 0; +} + +export function hasFocus(windowId) { + return mFocusState.has(windowId) +} + +export const counts = { + broadcast: {} +}; + +export function getOpenWindowIds() { + return mIsListening ? Array.from(mConnections.keys()) : []; +} + +export function sendMessage(message) { + if (!mIsListening || + Constants.IS_SIDEBAR) + return false; + + if (Array.isArray(message)) + log('Sending ', message.length, ' messages'); + + if (message.windowId) { + if (configs.loggingConnectionMessages) { + counts[message.windowId] = counts[message.windowId] || {}; + const localCounts = counts[message.windowId]; + localCounts[message.type] = localCounts[message.type] || 0; + localCounts[message.type]++; + } + const connections = mConnections.get(message.windowId); + if (!connections || connections.size == 0) + return false; + for (const connection of connections) { + sendMessageToPort(connection.port, message); + } + //port.postMessage(message); + return true; + } + + // broadcast + counts.broadcast[message.type] = counts.broadcast[message.type] || 0; + counts.broadcast[message.type]++; + for (const connections of mConnections.values()) { + if (!connections || connections.size == 0) + continue; + for (const connection of connections) { + sendMessageToPort(connection.port, message); + } + //port.postMessage(message); + } + return true; +} + +const mReservedTasks = new WeakMap(); + +// Se should not send messages immediately, instead we should throttle +// it and bulk-send multiple messages, for better user experience. +// Sending too much messages in one event loop may block everything +// and makes Firefox like frozen. +function sendMessageToPort(port, message) { + const task = mReservedTasks.get(port) || { messages: [] }; + task.messages.push(message); + mReservedTasks.set(port, task); + if (!task.onFrame) { + task.onFrame = () => { + delete task.onFrame; + const messages = task.messages; + task.messages = []; + port.postMessage(messages); + if (configs.debug) { + const types = mapAndFilterUniq(messages, + message => message.type || undefined).join(', '); + log(`${messages.length} messages sent (${types}):`, messages); + } + }; + // We should not use window.requestAnimationFrame for throttling, + // because it is quite lagged on some environment. Firefox may + // decelerate the method for an invisible document (the background + // page). + //window.requestAnimationFrame(task.onFrame); + setTimeout(task.onFrame, 0); + } +} + +if (Constants.IS_BACKGROUND) { + const matcher = new RegExp(`^${Constants.kCOMMAND_REQUEST_CONNECT_PREFIX}([0-9]+):(.+)$`); + browser.runtime.onConnect.addListener(port => { + if (!mIsListening || + !matcher.test(port.name)) + return; + + const windowId = parseInt(RegExp.$1); + const type = RegExp.$2; + const connection = { port, type }; + const connections = mConnections.get(windowId) || new Set(); + connections.add(connection); + mConnections.set(windowId, connections); + let connectionTimeoutTimer = null; + const updateTimeoutTimer = () => { + if (connectionTimeoutTimer) { + clearTimeout(connectionTimeoutTimer); + connectionTimeoutTimer = null; + } + // On slow situation (like having too many tabs - 5000 or more) + // we should wait more for the pong. Otherwise the vital check + // may produce needless reloadings even if the sidebar is still alive. + // See also https://github.com/piroor/treestyletab/issues/3130 + const timeout = configs.heartbeatInterval + Math.max(configs.connectionTimeoutDelay, TabsStore.tabs.size); + connectionTimeoutTimer = setTimeout(async () => { + log(`Missing heartbeat from window ${windowId}. Maybe disconnected or resumed.`); + try { + const pong = await browser.runtime.sendMessage({ + type: Constants.kCOMMAND_PING_TO_SIDEBAR, + windowId + }); + if (pong) { + log(`Sidebar for the window ${windowId} responded. Keep connected.`); + return; + } + } + catch(_error) { + } + log(`Sidebar for the window ${windowId} did not respond. Disconnect now.`); + cleanup(); // eslint-disable-line no-use-before-define + port.disconnect(); + }, timeout); + }; + const cleanup = _diconnectedPort => { + if (!port.onMessage.hasListener(receiver)) // eslint-disable-line no-use-before-define + return; + if (connectionTimeoutTimer) { + clearTimeout(connectionTimeoutTimer); + connectionTimeoutTimer = null; + } + connections.delete(connection); + if (connections.size == 0) { + mConnections.delete(windowId); + const sidebarWidthInWindow = { ...configs.sidebarWidthInWindow }; + delete sidebarWidthInWindow[windowId]; + configs.sidebarWidthInWindow = sidebarWidthInWindow; + } + port.onMessage.removeListener(receiver); // eslint-disable-line no-use-before-define + mReceivers.delete(windowId); + mFocusState.delete(windowId); + onDisconnected.dispatch(windowId, connections.size); + + // We need to notify this to some conetnt scripts, to destroy themselves. + /* + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_SIDEBAR_CLOSED, + windowId, + }); + */ + browser.windows.get(windowId, { populate: true }).then(async win => { + let count = 0; + for (const tab of win.tabs) { + count++; + if (count >= 20) { + // We should not block too long seconds on too much tabs case. + await wait(10); + count = 0; + } + try { + browser.tabs.sendMessage(tab.id, { + type: Constants.kCOMMAND_NOTIFY_SIDEBAR_CLOSED, + }); + } + catch (_error) { + } + } + }); + }; + const receiver = message => { + if (Array.isArray(message)) + return message.forEach(receiver); + if (message.type == Constants.kCONNECTION_HEARTBEAT) + updateTimeoutTimer(); + else + onMessage.dispatch(windowId, message); + }; + port.onMessage.addListener(receiver); + mReceivers.set(windowId, receiver); + onConnected.dispatch(windowId, connections.size); + port.onDisconnect.addListener(cleanup); + }); + + onMessage.addListener(async (windowId, message) => { + switch (message.type) { + case Constants.kNOTIFY_SIDEBAR_FOCUS: + mFocusState.set(windowId, true); + break; + + case Constants.kNOTIFY_SIDEBAR_BLUR: + mFocusState.delete(windowId); + break; + } + }); +} + +export function init() { + if (mIsListening) + return; + + mIsListening = true; +} + + +//=================================================================== +// Logging +//=================================================================== + +browser.runtime.onMessage.addListener((message, _sender) => { + if (!mIsListening || + !message || + typeof message != 'object' || + message.type != Constants.kCOMMAND_REQUEST_CONNECTION_MESSAGE_LOGS || + !Constants.IS_BACKGROUND) + return; + + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_RESPONSE_CONNECTION_MESSAGE_LOGS, + logs: JSON.parse(JSON.stringify(counts)) + }); +}); diff --git a/waterfox/browser/components/sidebar/common/sync-provider.js b/waterfox/browser/components/sidebar/common/sync-provider.js new file mode 100644 index 000000000000..3dcefa75313e --- /dev/null +++ b/waterfox/browser/components/sidebar/common/sync-provider.js @@ -0,0 +1,30 @@ +/* +# 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'; + +import * as Sync from '/common/sync.js'; + +Sync.registerExternalProvider({ + async getOtherDevices() { + return browser.waterfoxBridge.listSyncDevices(); + }, + + sendTabsToDevice(tabs, deviceId) { + if (!Array.isArray(tabs)) + tabs = [tabs]; + return browser.waterfoxBridge.sendToDevice(tabs.map(tab => tab.id), deviceId); + }, + + sendTabsToAllDevices(tabs) { + if (!Array.isArray(tabs)) + tabs = [tabs]; + return browser.waterfoxBridge.sendToDevice(tabs.map(tab => tab.id)); + }, + + manageDevices(windowId) { + return browser.waterfoxBridge.openSyncDeviceSettings(windowId) + }, +}); diff --git a/waterfox/browser/components/sidebar/common/sync.js b/waterfox/browser/components/sidebar/common/sync.js new file mode 100644 index 000000000000..a099be6e070d --- /dev/null +++ b/waterfox/browser/components/sidebar/common/sync.js @@ -0,0 +1,515 @@ +/* +# 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'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; + +import { + log as internalLogger, + configs, + notify, + wait, + getChunkedConfig, + setChunkedConfig, + isLinux, +} from './common.js'; +import * as Constants from '/common/constants.js'; +import * as TreeBehavior from '/common/tree-behavior.js'; + +import { Tab } from '/common/TreeItem.js'; + +function log(...args) { + internalLogger('common/sync', ...args); +} + +export const onMessage = new EventListenerManager(); +export const onNewDevice = new EventListenerManager(); +export const onUpdatedDevice = new EventListenerManager(); +export const onObsoleteDevice = new EventListenerManager(); + +const SEND_TABS_SIMULATOR_ID = 'send-tabs-to-device-simulator@piro.sakura.ne.jp'; + +// Workaround for https://github.com/piroor/treestyletab/blob/trunk/README.md#user-content-feature-requests-send-tab-tree-to-device-does-not-work +// and https://bugzilla.mozilla.org/show_bug.cgi?id=1417183 (resolved as WONTFIX) +// Firefox does not provide any API to access to Sync features directly. +// We need to provide it as experiments API or something way. +// This module is designed to work with a service provide which has features: +// * async getOtherDevices() +// - Returns an array of sync devices. +// - Retruned array should have 0 or more items like: +// { id: "identifier of the device", +// name: "name of the device", +// type: "type of the device (desktop, mobile, and so on)" } +// * sendTabsToDevice(tabs, deviceId) +// - Returns nothing. +// - Sends URLs of given tabs to the specified device. +// - The device is one of values returned by getOtherDevices(). +// * sendTabsToAllDevices(tabs) +// - Returns nothing. +// - Sends URLs of given tabs to all other devices. +// * manageDevices(windowId) +// - Returns nothing. +// - Opens settings page for Sync devices. +// It will appear as a tab in the specified window. + +let mExternalProvider = null; + +export function registerExternalProvider(provider) { + mExternalProvider = provider; +} + +export function hasExternalProvider() { + return !!mExternalProvider; +} + +let mMyDeviceInfo = null; + +async function getMyDeviceInfo() { + if (!configs.syncDeviceInfo || !configs.syncDeviceInfo.id) { + const newDeviceInfo = await generateDeviceInfo(); + if (!configs.syncDeviceInfo) + configs.syncDeviceInfo = newDeviceInfo; + } + return mMyDeviceInfo = configs.syncDeviceInfo; +} + +async function ensureDeviceInfoInitialized() { + await getMyDeviceInfo(); +} + +export async function waitUntilDeviceInfoInitialized() { + while (!configs.syncDeviceInfo) { + await wait(100); + } + mMyDeviceInfo = configs.syncDeviceInfo; +} + +let initialized = false; +let preChanges = []; + +function onConfigChanged(key, value = undefined) { + if (!initialized) { + preChanges.push({ key, value: value === undefined ? configs[key] : value }); + return; + } + switch (key) { + case 'syncOtherDevicesDetected': + if (!configs.syncAvailableNotified) { + configs.syncAvailableNotified = true; + notify({ + title: browser.i18n.getMessage('syncAvailable_notification_title'), + message: browser.i18n.getMessage(`syncAvailable_notification_message${isLinux() ? '_linux' : ''}`), + url: `${Constants.kSHORTHAND_URIS.options}#syncTabsToDeviceOptions`, + timeout: configs.syncAvailableNotificationTimeout + }); + } + return; + + case 'syncDevices': + // This may happen when all configs are reset. + // We need to try updating devices after syncDeviceInfo is completely cleared. + wait(100).then(updateDevices); + break; + + case 'syncDeviceInfo': + if (configs.syncDeviceInfo && + mMyDeviceInfo && + configs.syncDeviceInfo.id == mMyDeviceInfo.id && + configs.syncDeviceInfo.timestamp == mMyDeviceInfo.timestamp) + return; // ignore updating triggered by myself + mMyDeviceInfo = null; + updateSelf(); + break; + + default: + if (key.startsWith('chunkedSyncData')) + reserveToReceiveMessage(); + break; + } +} + +browser.runtime.onMessageExternal.addListener((message, sender) => { + if (!initialized || + !message || + typeof message != 'object' || + typeof message.type != 'string' || + sender.id != SEND_TABS_SIMULATOR_ID) + return; + + switch (message.type) { + case 'ready': + try { + browser.runtime.sendMessage(SEND_TABS_SIMULATOR_ID, { type: 'register-self' }).catch(_error => {}); + } + catch(_error) { + } + case 'device-added': + case 'device-updated': + case 'device-removed': + updateSelf(); + break; + } +}); + +export async function init() { + configs.$addObserver(onConfigChanged); // we need to register the listener to collect pre-sent changes + await configs.$loaded; + await ensureDeviceInfoInitialized(); + await updateSelf(); + initialized = true; + + reserveToReceiveMessage(); + window.setInterval(updateSelf, 1000 * 60 * 60 * 24); // update info every day! + + for (const change of preChanges) { + onConfigChanged(change.key, change.value); + } + preChanges = []; + + try { + browser.runtime.sendMessage(SEND_TABS_SIMULATOR_ID, { type: 'register-self' }).catch(_error => {}); + } + catch(_error) { + } +} + +export async function generateDeviceInfo({ name, icon } = {}) { + const [platformInfo, browserInfo] = await Promise.all([ + browser.runtime.getPlatformInfo(), + browser.runtime.getBrowserInfo() + ]); + return { + id: `device-${Date.now()}-${Math.round(Math.random() * 65000)}`, + name: name === undefined ? + browser.i18n.getMessage('syncDeviceDefaultName', [toHumanReadableOSName(platformInfo.os), browserInfo.name]) : + (name || null), + icon: icon || 'device-desktop' + }; +} + +// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/runtime/PlatformOs +function toHumanReadableOSName(os) { + switch (os) { + case 'mac': return 'macOS'; + case 'win': return 'Windows'; + case 'android': return 'Android'; + case 'cros': return 'Chrome OS'; + case 'linux': return 'Linux'; + case 'openbsd': return 'Open/FreeBSD'; + default: return 'Unknown Platform'; + } +} + +configs.$addObserver(key => { + switch (key) { + case 'syncUnsendableUrlPattern': + isSendableTab.unsendableUrlMatcher = null; + break; + + default: + break; + } +}); + +async function updateSelf() { + if (updateSelf.updating) + return; + + updateSelf.updating = true; + + const [devices] = await Promise.all([ + (async () => { + try { + return await browser.runtime.sendMessage(SEND_TABS_SIMULATOR_ID, { type: 'list-devices' }); + } + catch(_error) { + } + })(), + ensureDeviceInfoInitialized(), + ]); + if (devices) { + const myDeviceFromSimulator = devices.find(device => device.myself); + if (mMyDeviceInfo.simulatorId != myDeviceFromSimulator.id) + mMyDeviceInfo.simulatorId = myDeviceFromSimulator.id; + } + + configs.syncDeviceInfo = mMyDeviceInfo = { + ...clone(configs.syncDeviceInfo), + timestamp: Date.now(), + }; + + await updateDevices(); + + setTimeout(() => { + updateSelf.updating = false; + }, 250); +} + +async function updateDevices() { + if (updateDevices.updating) + return; + updateDevices.updating = true; + const [devicesFromSimulator] = await Promise.all([ + (async () => { + try { + return await browser.runtime.sendMessage(SEND_TABS_SIMULATOR_ID, { type: 'list-devices' }); + } + catch(_error) { + } + })(), + waitUntilDeviceInfoInitialized(), + ]); + + const remote = clone(configs.syncDevices); + const local = clone(configs.syncDevicesLocalCache); + log('devices updated: ', local, remote); + for (const [id, info] of Object.entries(remote)) { + if (id == mMyDeviceInfo.id) + continue; + local[id] = info; + if (id in local) { + log('updated device: ', info); + onUpdatedDevice.dispatch(info); + } + else { + log('new device: ', info); + onNewDevice.dispatch(info); + } + } + + if (devicesFromSimulator) { + const knownDeviceIdsFromSimulator = new Set(Object.values(local).map(device => device.simulatorId).filter(id => !!id)); + for (const deviceFromSimulator of devicesFromSimulator) { + if (knownDeviceIdsFromSimulator.has(deviceFromSimulator.id)) + continue; + const localId = `device-from-simulator:${deviceFromSimulator.id}`; + local[localId] = { + ...deviceFromSimulator, + id: localId, + }; + } + } + + for (const [id, info] of Object.entries(local)) { + if (id in remote || + id == mMyDeviceInfo.id) + continue; + log('obsolete device: ', info); + delete local[id]; + onObsoleteDevice.dispatch(info); + } + + if (configs.syncDeviceExpirationDays > 0) { + const expireDateInSeconds = Date.now() - (1000 * 60 * 60 * configs.syncDeviceExpirationDays); + for (const [id, info] of Object.entries(local)) { + if (info && + info.timestamp < expireDateInSeconds) { + delete local[id]; + log('expired device: ', info); + onObsoleteDevice.dispatch(info); + } + } + } + + local[mMyDeviceInfo.id] = clone(mMyDeviceInfo); + log('store myself: ', mMyDeviceInfo, local[mMyDeviceInfo.id]); + + if (!configs.syncOtherDevicesDetected && Object.keys(local).length > 1) + configs.syncOtherDevicesDetected = true; + + configs.syncDevices = local; + configs.syncDevicesLocalCache = clone(local); + setTimeout(() => { + updateDevices.updating = false; + }, 250); +} + +function reserveToReceiveMessage() { + if (reserveToReceiveMessage.reserved) + clearTimeout(reserveToReceiveMessage.reserved); + reserveToReceiveMessage.reserved = setTimeout(() => { + delete reserveToReceiveMessage.reserved; + receiveMessage(); + }, 250); +} + +async function receiveMessage() { + const myDeviceInfo = await getMyDeviceInfo(); + try { + const messages = readMessages(); + log('receiveMessage: queued messages => ', messages); + const restMessages = messages.filter(message => { + if (message.timestamp <= configs.syncLastMessageTimestamp) + return false; + if (message.to == myDeviceInfo.id) { + log('receiveMessage receive: ', message); + configs.syncLastMessageTimestamp = message.timestamp; + onMessage.dispatch(message); + return false; + } + return true; + }); + log('receiveMessage: restMessages => ', restMessages); + if (restMessages.length != messages.length) + writeMessages(restMessages); + } + catch(error) { + log('receiveMessage fatal error: ', error); + writeMessages([]); + } +} + +export async function sendMessage(to, data) { + const myDeviceInfo = await getMyDeviceInfo(); + try { + const messages = readMessages(); + messages.push({ + timestamp: Date.now(), + from: myDeviceInfo.id, + to, + data + }); + log('sendMessage: queued messages => ', messages); + writeMessages(messages); + } + catch(error) { + console.log('Sync.sendMessage: failed to send message ', error); + writeMessages([]); + } +} + +function readMessages() { + try { + return uniqMessages([ + ...JSON.parse(getChunkedConfig('chunkedSyncDataLocal') || '[]'), + ...JSON.parse(getChunkedConfig('chunkedSyncData') || '[]') + ]); + } + catch(error) { + log('failed to read messages: ', error); + return []; + } +} + +function writeMessages(messages) { + const stringified = JSON.stringify(messages || []); + setChunkedConfig('chunkedSyncDataLocal', stringified); + setChunkedConfig('chunkedSyncData', stringified); +} + +function uniqMessages(messages) { + const knownMessages = new Set(); + return messages.filter(message => { + const key = JSON.stringify(message); + if (knownMessages.has(key)) + return false; + knownMessages.add(key); + return true; + }); +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +export async function getOtherDevices() { + if (mExternalProvider) + return mExternalProvider.getOtherDevices(); + + await waitUntilDeviceInfoInitialized(); + + const devices = configs.syncDevices || {}; + const result = []; + for (const [id, info] of Object.entries(devices)) { + if (id == mMyDeviceInfo.id || + !info.id /* ignore invalid device info accidentally saved (see also https://github.com/piroor/treestyletab/issues/2922 ) */) + continue; + result.push(info); + } + return result.sort((a, b) => a.name > b.name); +} + +export function getDeviceName(id) { + const devices = configs.syncDevices || {}; + if (!(id in devices) || !devices[id]) + return browser.i18n.getMessage('syncDeviceUnknownDevice'); + return String(devices[id].name || '').trim() || browser.i18n.getMessage('syncDeviceMissingDeviceName'); +} + +// https://searchfox.org/mozilla-central/rev/d866b96d74ec2a63f09ee418f048d23f4fd379a2/browser/base/content/browser-sync.js#1176 +export function isSendableTab(tab) { + if (!tab.url || + tab.url.length > 65535) + return false; + + if (!isSendableTab.unsendableUrlMatcher) + isSendableTab.unsendableUrlMatcher = new RegExp(configs.syncUnsendableUrlPattern); + return !isSendableTab.unsendableUrlMatcher.test(tab.url); +} + +export function sendTabsToDevice(tabs, { to, recursively } = {}) { + if (recursively) + tabs = Tab.collectRootTabs(tabs).map(tab => [tab, ...tab.$TST.descendants]).flat(); + tabs = tabs.filter(isSendableTab); + + if (mExternalProvider) + return mExternalProvider.sendTabsToDevice(tabs, to); + + sendMessage(to, getTabsDataToSend(tabs)); + + const multiple = tabs.length > 1 ? '_multiple' : ''; + notify({ + title: browser.i18n.getMessage( + `sentTabs_notification_title${multiple}`, + [getDeviceName(to)] + ), + message: browser.i18n.getMessage( + `sentTabs_notification_message${multiple}`, + [getDeviceName(to)] + ), + timeout: configs.syncSentTabsNotificationTimeout + }); +} + +export async function sendTabsToAllDevices(tabs, { recursively } = {}) { + if (recursively) + tabs = Tab.collectRootTabs(tabs).map(tab => [tab, ...tab.$TST.descendants]).flat(); + tabs = tabs.filter(isSendableTab); + + if (mExternalProvider) + return mExternalProvider.sendTabsToAllDevices(tabs); + + const data = getTabsDataToSend(tabs); + const devices = await getOtherDevices(); + for (const device of devices) { + sendMessage(device.id, data); + } + + const multiple = tabs.length > 1 ? '_multiple' : ''; + notify({ + title: browser.i18n.getMessage(`sentTabsToAllDevices_notification_title${multiple}`), + message: browser.i18n.getMessage(`sentTabsToAllDevices_notification_message${multiple}`), + timeout: configs.syncSentTabsNotificationTimeout + }); +} + +function getTabsDataToSend(tabs) { + return { + type: Constants.kSYNC_DATA_TYPE_TABS, + tabs: tabs.map(tab => ({ url: tab.url, cookieStoreId: tab.cookieStoreId })), + structure : TreeBehavior.getTreeStructureFromTabs(tabs).map(item => item.parent) + }; +} + +export function manageDevices(windowId) { + if (mExternalProvider) + return mExternalProvider.manageDevices(windowId); + + browser.tabs.create({ + windowId, + url: `${Constants.kSHORTHAND_URIS.options}#syncTabsToDeviceOptions` + }); +} diff --git a/waterfox/browser/components/sidebar/common/tabs-internal-operation.js b/waterfox/browser/components/sidebar/common/tabs-internal-operation.js new file mode 100644 index 000000000000..bf83b2a2b9d8 --- /dev/null +++ b/waterfox/browser/components/sidebar/common/tabs-internal-operation.js @@ -0,0 +1,396 @@ +/* +# 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'; + +// internal operations means operations bypassing WebExtensions' tabs APIs. + +import EventListenerManager from '/extlib/EventListenerManager.js'; + +import { + log as internalLogger, + dumpTab, + mapAndFilter, + configs, + wait, +} from './common.js'; +import * as ApiTabs from './api-tabs.js'; +import * as Constants from './constants.js'; +import * as SidebarConnection from './sidebar-connection.js'; +import * as TabsStore from './tabs-store.js'; +import * as TabsUpdate from './tabs-update.js'; + +import { Tab, TreeItem } from '/common/TreeItem.js'; + +function log(...args) { + internalLogger('common/tabs-internal-operation', ...args); +} + +export const onBeforeTabsRemove = new EventListenerManager(); + +export async function activateTab(tab, { byMouseOperation, keepMultiselection, silently } = {}) { + if (!Constants.IS_BACKGROUND) + throw new Error('Error: TabsInternalOperation.activateTab is available only on the background page, use a `kCOMMAND_ACTIVATE_TAB` message instead.'); + + tab = TabsStore.ensureLivingItem(tab); + if (!tab) + return; + log('activateTab: ', dumpTab(tab)); + const win = TabsStore.windows.get(tab.windowId); + win.internallyFocusingTabs.add(tab.id); + if (byMouseOperation) + win.internallyFocusingByMouseTabs.add(tab.id); + if (silently) + win.internallyFocusingSilentlyTabs.add(tab.id); + let tabs = [tab]; + if (tab.$TST.hasOtherHighlighted && + keepMultiselection) { + const highlightedTabs = Tab.getHighlightedTabs(tab.windowId); + if (highlightedTabs.some(highlightedTab => highlightedTab.id == tab.id)) { + // switch active tab with highlighted state + tabs = tabs.concat(mapAndFilter(highlightedTabs, + highlightedTab => highlightedTab.id != tab.id && highlightedTab || undefined)); + } + } + if (tabs.length == 1) + win.tabsToBeHighlightedAlone.add(tab.id); + highlightTabs(tabs); +} + +export async function blurTab(bluredTabs, { windowId, silently, keepDiscarded } = {}) { + if (bluredTabs && + !Array.isArray(bluredTabs)) + bluredTabs = [bluredTabs]; + + const bluredTabIds = new Set(Array.from(bluredTabs || [], tab => tab.id || tab)); + + // First, try to find successor based on successorTabId from left tabs. + let successorTab = Tab.get(bluredTabs.find(tab => tab.active)?.successorTabId); + const scannedTabIds = new Set(); + while (bluredTabIds.has(successorTab?.id) || + (keepDiscarded && + successorTab?.discarded)) { + if (scannedTabIds.has(successorTab.id)) + break; // prevent infinite loop! + scannedTabIds.add(successorTab.id); + const nextSuccessorTab = (successorTab.successorTabId > 0 && successorTab.successorTabId != successorTab.id) ? + Tab.get(successorTab.successorTabId) : + null; + if (!nextSuccessorTab) + break; + successorTab = nextSuccessorTab; + } + log('blurTab/step 1: found successor = ', successorTab?.id); + + // Second, try to detect successor based on their order. + if (!successorTab || + bluredTabIds.has(successorTab.id) || + (keepDiscarded && + successorTab.discarded)) { + if (successorTab) + log(' => it cannot become the successor, find again'); + let bluredTabsFound = false; + for (const tab of Tab.getVisibleTabs(windowId || bluredTabs[0].windowId)) { + if (keepDiscarded && + tab.discarded) { + continue; + } + const blured = bluredTabIds.has(tab.id); + if (blured) + bluredTabsFound = true; + if (!bluredTabsFound) + successorTab = tab; + if (bluredTabsFound && + !blured) { + successorTab = tab; + break; + } + } + log('blurTab/step 2: found successor = ', successorTab?.id); + } + + if (successorTab) + await activateTab(successorTab, { silently }); + return successorTab; +} + +export function removeTab(tab) { + return removeTabs([tab]); +} + +export async function removeTabs(tabs, { keepDescendants, byMouseOperation, originalStructure, triggerTab } = {}) { + if (!Constants.IS_BACKGROUND) + throw new Error('TabsInternalOperation.removeTabs is available only on the background page, use a `kCOMMAND_REMOVE_TABS_INTERNALLY` message instead.'); + + log('TabsInternalOperation.removeTabs: ', () => tabs.map(dumpTab)); + if (tabs.length == 0) + return; + + await onBeforeTabsRemove.dispatch(tabs); + + const win = TabsStore.windows.get(tabs[0].windowId); + const tabIds = []; + let willChangeFocus = false; + tabs = tabs.filter(tab => { + if ((!win || + !win.internalClosingTabs.has(tab.id)) && + TabsStore.ensureLivingItem(tab)) { + tabIds.push(tab.id); + if (tab.active) + willChangeFocus = true; + return true; + } + return false; + }); + log(' => ', () => tabs.map(dumpTab)); + if (!tabs.length) + return; + + if (win) { + // Flag tabs to be closed at a time. With this flag TST skips some + // operations on tab close (for example, opening a group tab to replace + // a closed parent tab to keep the tree structure). + for (const tab of tabs) { + win.internalClosingTabs.add(tab.id); + tab.$TST.addState(Constants.kTAB_STATE_TO_BE_REMOVED); + clearCache(tab); + if (keepDescendants) + win.keepDescendantsTabs.add(tab.id); + if (willChangeFocus && byMouseOperation) { + win.internallyFocusingByMouseTabs.add(tab.id); + setTimeout(() => { // the operation can be canceled + win.internallyFocusingByMouseTabs.delete(tab.id); + }, 250); + } + } + } + + const sortedTabs = TreeItem.sort(Array.from(tabs)); + Tab.onMultipleTabsRemoving.dispatch(sortedTabs, { triggerTab, originalStructure }); + + const promisedRemoved = browser.tabs.remove(tabIds).catch(ApiTabs.createErrorHandler(ApiTabs.handleMissingTabError)); + if (win) { + promisedRemoved.then(() => { + // "beforeunload" listeners in tabs blocks the operation and the + // returned promise is resolved after all "beforeunload" listeners + // are processed and "browser.tabs.onRemoved()" listeners are + // processed for really closed tabs. + // In other words, there may be some "canceled tab close"s and + // we need to clear "to-be-closed" flags for such tabs. + // See also: https://github.com/piroor/treestyletab/issues/2384 + const canceledTabs = new Set(tabs.filter(tab => tab.$TST && !tab.$TST.destroyed)); + log(`${canceledTabs.size} tabs may be canceled to close.`); + if (canceledTabs.size == 0) { + Tab.onMultipleTabsRemoved.dispatch(sortedTabs, { triggerTab, originalStructure }); + return; + } + log(`Clearing "to-be-removed" flag for requested ${tabs.length} tabs...`); + for (const tab of canceledTabs) { + tab.$TST.removeState(Constants.kTAB_STATE_TO_BE_REMOVED); + win.internalClosingTabs.delete(tab.id); + if (keepDescendants) + win.keepDescendantsTabs.delete(tab.id); + } + Tab.onMultipleTabsRemoved.dispatch(sortedTabs.filter(tab => !canceledTabs.has(tab)), { triggerTab, originalStructure }); + }); + } + return promisedRemoved; +} + +export function setTabActive(tab) { + const oldActiveTabs = clearOldActiveStateInWindow(tab.windowId, tab); + tab.active = true; + tab.$TST.addState(Constants.kTAB_STATE_ACTIVE); + tab.$TST.removeState(Constants.kTAB_STATE_NOT_ACTIVATED_SINCE_LOAD); + tab.$TST.removeState(Constants.kTAB_STATE_UNREAD, { permanently: true }); + tab.$TST.removeState(Constants.kTAB_STATE_PENDING, { broadcast: Constants.IS_BACKGROUND }); + TabsStore.activeTabsInWindow.get(tab.windowId).add(tab); + TabsStore.activeTabInWindow.set(tab.windowId, tab); + Tab.onActivated.dispatch(tab); + return oldActiveTabs; +} + +export function clearOldActiveStateInWindow(windowId, exception) { + const oldTabs = TabsStore.activeTabsInWindow.get(windowId); + for (const oldTab of oldTabs) { + if (oldTab.id == exception?.id) + continue; + oldTab.$TST.removeState(Constants.kTAB_STATE_ACTIVE); + oldTab.active = false; + oldTabs.delete(oldTab); + Tab.onUnactivated.dispatch(oldTab); + } + return Array.from(oldTabs); +} + +export function clearCache(tab) { + if (!tab) + return; + const errorHandler = ApiTabs.createErrorSuppressor(ApiTabs.handleMissingTabError); + for (const key of Constants.kCACHE_KEYS) { + browser.sessions.removeTabValue(tab.id, key).catch(errorHandler); + } +} + +// Note: this treats the first specified tab as active. +export async function highlightTabs(tabs, { inheritToCollapsedDescendants } = {}) { + if (!Constants.IS_BACKGROUND) + throw new Error('TabsInternalOperation.highlightTabs is available only on the background page, use a `kCOMMAND_HIGHLIGHT_TABS` message instead.'); + + if (!tabs || tabs.length == 0) + throw new Error('TabsInternalOperation.highlightTabs requires one or more tabs.'); + + const highlightedTabs = Tab.getHighlightedTabs(tabs[0].windowId); + if (tabs.map(tab => tab.id).join('\n') == highlightedTabs.map(tab => tab.id).join('\n')) { + log('highlightTabs: already highlighted'); + return; + } + + log('setting tabs highlighted ', tabs, { inheritToCollapsedDescendants }); + + const startAtTimestamp = Date.now(); + const startAt = `${Date.now()}-${parseInt(Math.random() * 65000)}`; + highlightTabs.lastStartedAt = startAt; + + const windowId = tabs[0].windowId; + const win = TabsStore.windows.get(windowId); + + win.highlightingTabs.clear(); + win.tabsMovedWhileHighlighting = false; + const tabIds = tabs.map(tab => { + win.highlightingTabs.add(tab.id); + return tab.id; + }); + const toBeHighlightedTabIds = new Set([...win.highlightingTabs]); + + TabsUpdate.updateTabsHighlighted({ + windowId, + tabIds, + inheritToCollapsedDescendants, + }); + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_HIGHLIGHTED_TABS_CHANGED, + windowId, + tabIds, + }); + + // for better performance, we should not call browser.tabs.update() for each tab. + const highlightedTabIds = new Set(tabIds); + const activeTab = Tab.getActiveTab(windowId); + const indices = mapAndFilter(highlightedTabIds, + id => id == activeTab.id ? undefined : Tab.get(id).index); + if (highlightedTabIds.has(activeTab.id)) + indices.unshift(activeTab.index); + + // highlight tabs progressively, because massinve change at once may block updating of highlighted appearance of tabs. + let count = 1; // 1 is for setActive() + while (highlightTabs.lastStartedAt == startAt) { + count += (configs.provressiveHighlightingStep <= 0 ? Number.MAX_SAFE_INTEGER : configs.provressiveHighlightingStep); + await browser.tabs.highlight({ + windowId, + populate: false, + tabs: indices.slice(0, count), + }).catch(ApiTabs.createErrorSuppressor()); + const progress = Math.ceil(Math.min(indices.length, count) / indices.length * 100); + log(`highlightTabs: ${progress} %`); + await wait(configs.progressievHighlightingInterval); + + if (win.tabsMovedWhileHighlighting) { + log('highlightTabs: tabs are moved while highlighting, retry'); + await wait(250); + return highlightTabs(tabs, { inheritToCollapsedDescendants }); + } + + if (win.highlightingTabs.size < toBeHighlightedTabIds.size) { + log('highlightTabs: someone cleared multiselection while in-progress ', toBeHighlightedTabIds.size, win.highlightingTabs.size); + break; + } + + const unifiedHighlightTabIds = new Set([...toBeHighlightedTabIds, ...win.highlightingTabs]); + if (unifiedHighlightTabIds.size != toBeHighlightedTabIds.size) { + log('highlightTabs: someone tried multiselection again while in-progress ', toBeHighlightedTabIds.size, win.highlightingTabs.size); + break; + } + + if (count >= indices.length) + break; + + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TABS_HIGHLIGHTING_IN_PROGRESS, + windowId, + progress, + }); + } + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TABS_HIGHLIGHTING_COMPLETE, + windowId, + }); + log('highlightTabs done. ', Date.now() - startAtTimestamp, ' msec'); +} + + +SidebarConnection.onMessage.addListener(async (windowId, message) => { + switch (message.type) { + case Constants.kCOMMAND_ACTIVATE_TAB: { + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + if (!tab) + return; + activateTab(tab, { + byMouseOperation: message.byMouseOperation, + keepMultiselection: message.keepMultiselection, + silently: message.silently + }); + }; break; + + case Constants.kCOMMAND_HIGHLIGHT_TABS: { + await Tab.waitUntilTracked(message.tabIds); + highlightTabs(message.tabIds.map(id => Tab.get(id)), { + inheritToCollapsedDescendants: message.inheritToCollapsedDescendants, + }); + }; break; + + case Constants.kCOMMAND_REMOVE_TABS_INTERNALLY: + await Tab.waitUntilTracked(message.tabIds); + removeTabs(message.tabIds.map(id => Tab.get(id)), { + byMouseOperation: message.byMouseOperation, + keepDescendants: message.keepDescendants + }); + break; + + case Constants.kCOMMAND_REMOVE_TABS_BY_MOUSE_OPERATION: + await Tab.waitUntilTracked(message.tabIds); + removeTabs(message.tabIds.map(id => Tab.get(id)), { + byMouseOperation: true, + keepDescendants: message.keepDescendants + }); + break; + } +}); + +if (Constants.IS_BACKGROUND) { + browser.runtime.onMessage.addListener((message, _sender) => { + switch (message.type) { + // for operations from group-tab.html + case Constants.kCOMMAND_REMOVE_TABS_INTERNALLY: + Tab.waitUntilTracked(message.tabIds).then(() => { + removeTabs(message.tabIds.map(id => Tab.get(id)), { + byMouseOperation: message.byMouseOperation, + keepDescendants: message.keepDescendants, + }); + }); + break; + + // for automated tests + case Constants.kCOMMAND_REMOVE_TABS_BY_MOUSE_OPERATION: + Tab.waitUntilTracked(message.tabIds).then(() => { + removeTabs(message.tabIds.map(id => Tab.get(id)), { + byMouseOperation: true + }); + }); + break; + } + }); +} diff --git a/waterfox/browser/components/sidebar/common/tabs-store.js b/waterfox/browser/components/sidebar/common/tabs-store.js new file mode 100644 index 000000000000..8d86a9beca88 --- /dev/null +++ b/waterfox/browser/components/sidebar/common/tabs-store.js @@ -0,0 +1,798 @@ +/* +# 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'; + +import { + log as internalLogger, + configs +} from './common.js'; +import * as Constants from './constants.js'; + +// eslint-disable-next-line no-unused-vars +function log(...args) { + internalLogger('common/tabs', ...args); +} + + +let mTargetWindow; + +export function setCurrentWindowId(targetWindow) { + return mTargetWindow = targetWindow; +} + +export function getCurrentWindowId() { + return mTargetWindow; +} + + +//=================================================================== +// Tab Tracking +//=================================================================== + +export const windows = new Map(); +export const tabs = new Map(); +export const tabGroups = new Map(); +export const tabsByUniqueId = new Map(); + +export function clear() { + tabs.clear(); + tabsByUniqueId.clear(); +} + +export const queryLogs = []; +const MAX_LOGS = 100000; + +const MATCHING_ATTRIBUTES = ` +active +attention +audible +autoDiscardable +cookieStoreId +discarded +favIconUrl +hidden +highlighted +id +incognito +index +isArticle +isInReaderMode +pinned +sessionId +status +successorId +title +url +`.trim().split(/\s+/); + +export function queryAll(query) { + if (configs.loggingQueries) { + queryLogs.push(query); + queryLogs.splice(0, Math.max(0, queryLogs.length - MAX_LOGS)); + if (query.tabs?.name) + query.indexedTabs = query.tabs.name; + } + fixupQuery(query); + const startAt = Date.now(); + if (query.windowId || query.ordered) { + let tabs = []; + for (const win of windows.values()) { + if (query.windowId && !matched(win.id, query.windowId)) + continue; + const [sourceTabs, offset] = sourceTabsForQuery(query, win); + tabs = tabs.concat(query.iterator ? getMatchedTabsIterator(sourceTabs, query, offset) : extractMatchedTabs(sourceTabs, query, offset)); + } + query.elapsed = Date.now() - startAt; + if (query.iterator) { + return (function* () { + for (const tabsIterator of tabs) { + for (const tab of tabsIterator) { + yield tab; + } + } + })(); + } + else { + return tabs; + } + } + else { + const sourceTabs = (query.tabs || tabs).values(); + const matchedTabs = query.iterator ? getMatchedTabsIterator(sourceTabs, query) : extractMatchedTabs(sourceTabs, query); + query.elapsed = Date.now() - startAt; + return matchedTabs; + } +} + +function sourceTabsForQuery(query, win) { + let offset = 0; + if (!query.ordered) + return [query.tabs?.values() || win.tabs.values(), offset]; + let fromId; + let toId = query.toId; + if (typeof query.index == 'number') { + fromId = win.order[query.index]; + offset = query.index; + } + if (typeof query.fromIndex == 'number') { + fromId = win.order[query.fromIndex]; + offset = query.fromIndex; + } + if (typeof query.toIndex == 'number') { + toId = win.order[query.toIndex]; + } + if (typeof fromId != 'number') { + fromId = query.fromId; + offset = win.order.indexOf(query.fromId); + } + if (query.last || query.reversed) + return [win.getReversedOrderedTabs(fromId, toId, query.tabs), offset]; + return [win.getOrderedTabs(fromId, toId, query.tabs), offset]; +} + +function extractMatchedTabs(tabsInStore, query, offset) { + const matchedTabs = []; + let firstTime = true; + let logicalIndex = offset || 0; + for (const tab of tabsInStore) { + if (!tabs.has(tab.id) || + (!query.skipMatching && + !matchedWithQuery(tab, query))) + continue; + + if (!firstTime) + logicalIndex++; + firstTime = false; + if ('logicalIndex' in query && + !matched(logicalIndex, query.logicalIndex)) + continue; + + matchedTabs.push(tab); + if (query.first || query.last) + break; + } + return matchedTabs; +} + +function* getMatchedTabsIterator(tabsInStore, query, offset) { + let firstTime = true; + let logicalIndex = offset || 0; + for (const tab of tabsInStore) { + if (!tabs.has(tab.id) || + (!query.skipMatching && + !matchedWithQuery(tab, query))) + continue; + + if (!firstTime) + logicalIndex++; + firstTime = false; + if ('logicalIndex' in query && + !matched(logicalIndex, query.logicalIndex)) + continue; + + yield tab; + if (query.first || query.last) + break; + } +} + +function matchedWithQuery(tab, query) { + for (const attribute of MATCHING_ATTRIBUTES) { + if (attribute in query && + !matched(tab[attribute], query[attribute])) + return false; + const invertedAttribute = `!${attribute}`; + if (invertedAttribute in query && + matched(tab[attribute], query[invertedAttribute])) + return false; + } + + if (!tab.$TST) + return false; + + const tabStates = tab.$TST.states; + if ('states' in query && tabStates) { + const queryStates = query.states; + for (let i = 0, maxi = queryStates.length; i < maxi; i += 2) { + const state = queryStates[i]; + const pattern = queryStates[i+1]; + if (!matched(tabStates.has(state), pattern)) + return false; + } + } + const tabAttributes = tab.$TST.attributes; + if ('attributes' in query && tabAttributes) { + const queryAttributes = query.attributes; + for (let i = 0, maxi = queryAttributes.length; i < maxi; i += 2) { + const attribute = queryAttributes[i]; + const pattern = queryAttributes[i+1]; + if (!matched(tabAttributes[attribute], pattern)) + return false; + } + } + + if (query.living && + !ensureLivingItem(tab)) + return false; + if (query.normal && + (tab.hidden || + tabStates.has(Constants.kTAB_STATE_SHOWING) || + tab.pinned)) + return false; + if (query.pinned && + (tab.hidden || + tabStates.has(Constants.kTAB_STATE_SHOWING) || + !tab.pinned)) + return false; + if (query.visible && + ((tabStates.has(Constants.kTAB_STATE_COLLAPSED) && + !tabStates.has(Constants.kTAB_STATE_EXPANDING)) || + tab.hidden || + tabStates.has(Constants.kTAB_STATE_SHOWING))) + return false; + if (query.hidden && + !tab.hidden) + return false; + if (query.controllable && + (tab.hidden || + tabStates.has(Constants.kTAB_STATE_SHOWING))) + return false; + if ('hasChild' in query && + query.hasChild != tab.$TST.hasChild) + return false; + if ('hasParent' in query && + query.hasParent != tab.$TST.hasParent) + return false; + if ('childOf' in query && + !tab.$TST.parentId != query.childOf) + return false; + if ('descendantOf' in query && + !tab.$TST.ancestorIds.includes(query.descendantOf)) + return false; + if (query.groupId && + tab.groupId != query.groupId) + return false; + + return true; +} + +function matched(value, pattern) { + if (pattern instanceof RegExp && + !pattern.test(String(value))) + return false; + if (pattern instanceof Set && + !pattern.has(value)) + return false; + if (Array.isArray(pattern) && + !pattern.includes(value)) + return false; + if (typeof pattern == 'function' && + !pattern(value)) + return false; + if (typeof pattern == 'boolean' && + !!value !== pattern) + return false; + if (typeof pattern == 'string' && + String(value || '') != pattern) + return false; + if (typeof pattern == 'number' && + value != pattern) + return false; + return true; +} + +export function query(query) { + if (configs.loggingQueries) { + queryLogs.push(query); + queryLogs.splice(0, Math.max(0, queryLogs.length - MAX_LOGS)); + if (query.tabs?.name) + query.indexedTabs = query.tabs.name; + } + fixupQuery(query); + if (query.last) + query.ordered = true; + else + query.first = true; + const startAt = Date.now(); + let tabs = []; + if (query.windowId || query.ordered) { + for (const win of windows.values()) { + if (query.windowId && !matched(win.id, query.windowId)) + continue; + const [sourceTabs, offset] = sourceTabsForQuery(query, win); + tabs = tabs.concat(extractMatchedTabs(sourceTabs, query, offset)); + if (tabs.length > 0) + break; + } + } + else { + tabs = extractMatchedTabs((query.tabs ||tabs).values(), query); + } + query.elapsed = Date.now() - startAt; + return tabs.length > 0 ? tabs[0] : null ; +} + +function fixupQuery(query) { + if (query.fromId || + query.toId || + typeof query.index == 'number' || + typeof query.fromIndex == 'number' || + typeof query.logicalIndex == 'number') + query.ordered = true; + if ((query.normal || + query.visible || + query.controllable || + query.pinned) && + !('living' in query)) + query.living = true; +} + + +//=================================================================== +// Indexes for optimization +//=================================================================== + +export const activeTabInWindow = new Map(); +export const activeTabsInWindow = new Map(); +export const bundledActiveTabsInWindow = new Map(); +export const livingTabsInWindow = new Map(); +export const controllableTabsInWindow = new Map(); +export const removingTabsInWindow = new Map(); +export const removedTabsInWindow = new Map(); +export const visibleTabsInWindow = new Map(); +export const expandedTabsInWindow = new Map(); +export const selectedTabsInWindow = new Map(); +export const highlightedTabsInWindow = new Map(); +export const pinnedTabsInWindow = new Map(); +export const unpinnedTabsInWindow = new Map(); +export const rootTabsInWindow = new Map(); +export const groupTabsInWindow = new Map(); +export const toBeExpandedTabsInWindow = new Map(); +export const subtreeCollapsableTabsInWindow = new Map(); +export const draggingTabsInWindow = new Map(); +export const duplicatingTabsInWindow = new Map(); +export const toBeGroupedTabsInWindow = new Map(); +export const nativelyGroupedTabsInWindow = new Map(); +export const loadingTabsInWindow = new Map(); +export const unsynchronizedTabsInWindow = new Map(); +export const virtualScrollRenderableTabsInWindow = new Map(); +export const scrollPositionCalculationTargetTabsInWindow = new Map(); +export const canBecomeStickyTabsInWindow = new Map(); + +function createMapWithName(name) { + const map = new Map(); + map.name = name; + return map; +} + +export function prepareIndexesForWindow(windowId) { + activeTabsInWindow.set(windowId, new Set()); + bundledActiveTabsInWindow.set(windowId, createMapWithName(`bundled active tabs in window ${windowId}`)); + livingTabsInWindow.set(windowId, createMapWithName(`living tabs in window ${windowId}`)); + controllableTabsInWindow.set(windowId, createMapWithName(`controllable tabs in window ${windowId}`)); + removingTabsInWindow.set(windowId, createMapWithName(`removing tabs in window ${windowId}`)); + removedTabsInWindow.set(windowId, createMapWithName(`removed tabs in window ${windowId}`)); + visibleTabsInWindow.set(windowId, createMapWithName(`visible tabs in window ${windowId}`)); + expandedTabsInWindow.set(windowId, createMapWithName(`expanded tabs in window ${windowId}`)); + selectedTabsInWindow.set(windowId, createMapWithName(`selected tabs in window ${windowId}`)); + highlightedTabsInWindow.set(windowId, createMapWithName(`highlighted tabs in window ${windowId}`)); + pinnedTabsInWindow.set(windowId, createMapWithName(`pinned tabs in window ${windowId}`)); + unpinnedTabsInWindow.set(windowId, createMapWithName(`unpinned tabs in window ${windowId}`)); + rootTabsInWindow.set(windowId, createMapWithName(`root tabs in window ${windowId}`)); + groupTabsInWindow.set(windowId, createMapWithName(`group tabs in window ${windowId}`)); + toBeExpandedTabsInWindow.set(windowId, createMapWithName(`to-be-expanded tabs in window ${windowId}`)); + subtreeCollapsableTabsInWindow.set(windowId, createMapWithName(`collapsable parent tabs in window ${windowId}`)); + draggingTabsInWindow.set(windowId, createMapWithName(`dragging tabs in window ${windowId}`)); + duplicatingTabsInWindow.set(windowId, createMapWithName(`duplicating tabs in window ${windowId}`)); + toBeGroupedTabsInWindow.set(windowId, createMapWithName(`to-be-grouped tabs in window ${windowId}`)); + nativelyGroupedTabsInWindow.set(windowId, createMapWithName(`natively grouped tabs in window ${windowId}`)); + loadingTabsInWindow.set(windowId, createMapWithName(`loading tabs in window ${windowId}`)); + unsynchronizedTabsInWindow.set(windowId, createMapWithName(`unsynchronized tabs in window ${windowId}`)); + virtualScrollRenderableTabsInWindow.set(windowId, createMapWithName(`virtual scroll renderable tabs in window ${windowId}`)); + scrollPositionCalculationTargetTabsInWindow.set(windowId, createMapWithName(`scroll position calculation target tabs in window ${windowId}`)); + canBecomeStickyTabsInWindow.set(windowId, createMapWithName(`can become sticky tabs in window ${windowId}`)); +} + +export function unprepareIndexesForWindow(windowId) { + activeTabInWindow.delete(windowId); + activeTabsInWindow.delete(windowId); + bundledActiveTabsInWindow.delete(windowId); + livingTabsInWindow.delete(windowId); + controllableTabsInWindow.delete(windowId); + removingTabsInWindow.delete(windowId); + removedTabsInWindow.delete(windowId); + visibleTabsInWindow.delete(windowId); + expandedTabsInWindow.delete(windowId); + selectedTabsInWindow.delete(windowId); + highlightedTabsInWindow.delete(windowId); + pinnedTabsInWindow.delete(windowId); + unpinnedTabsInWindow.delete(windowId); + rootTabsInWindow.delete(windowId); + groupTabsInWindow.delete(windowId); + toBeExpandedTabsInWindow.delete(windowId); + subtreeCollapsableTabsInWindow.delete(windowId); + toBeGroupedTabsInWindow.delete(windowId); + nativelyGroupedTabsInWindow.delete(windowId); + loadingTabsInWindow.delete(windowId); + unsynchronizedTabsInWindow.delete(windowId); + virtualScrollRenderableTabsInWindow.delete(windowId); + scrollPositionCalculationTargetTabsInWindow.delete(windowId); + canBecomeStickyTabsInWindow.delete(windowId); +} + +export function getTabsMap(tabsStore, windowId = null) { + return windowId ? + tabsStore.get(windowId) : + new Map([...tabsStore.values()].map(tabs => [...tabs.entries()]).flat()); +} + +export function updateIndexesForTab(tab) { + addLivingTab(tab); + + if (!tab.hidden) + addControllableTab(tab); + else + removeControllableTab(tab); + + if (tab.$TST.collapsed) + removeExpandedTab(tab); + else + addExpandedTab(tab); + + if (tab.hidden || tab.$TST.collapsed) + removeVisibleTab(tab); + else + addVisibleTab(tab); + + if (tab.$TST.states.has(Constants.kTAB_STATE_SELECTED)) + addSelectedTab(tab); + else + removeSelectedTab(tab); + + if (tab.highlighted) + addHighlightedTab(tab); + else + removeHighlightedTab(tab); + + if (tab.pinned) { + removeUnpinnedTab(tab); + addPinnedTab(tab); + } + else { + removePinnedTab(tab); + addUnpinnedTab(tab); + } + + if (tab.$TST.isGroupTab) + addGroupTab(tab); + else + removeGroupTab(tab); + + if (tab.$TST.duplicating) + addDuplicatingTab(tab); + else + removeDuplicatingTab(tab); + + if (tab.$TST.getAttribute(Constants.kPERSISTENT_ORIGINAL_OPENER_TAB_ID) && + !tab.$TST.getAttribute(Constants.kPERSISTENT_ALREADY_GROUPED_FOR_PINNED_OPENER)) + addToBeGroupedTab(tab); + else + removeToBeGroupedTab(tab); + + if (tab.$TST.parent) + removeRootTab(tab); + else + addRootTab(tab); + + if (tab.$TST.hasChild && + !tab.$TST.subtreeCollapsed && + !tab.$TST.collapsed) + addSubtreeCollapsableTab(tab); + else + removeSubtreeCollapsableTab(tab); + + if (tab.status == 'loading') + addLoadingTab(tab); + else + removeLoadingTab(tab); + + if (tab.$TST.states.has(Constants.kTAB_STATE_BUNDLED_ACTIVE)) + addBundledActiveTab(tab); + else + removeBundledActiveTab(tab); + + if (tab.groupId && tab.groupId != -1) + addNativelyGroupedTab(tab); + else + removeNativelyGroupedTab(tab); + + updateVirtualScrollRenderabilityIndexForTab(tab); + + if (tab.$TST.states.has(Constants.kTAB_STATE_COLLAPSED_DONE)) + removeScrollPositionCalculationTargetTab(tab); + else + addScrollPositionCalculationTargetTab(tab); + + if (tab.$TST.canBecomeSticky) + addCanBecomeStickyTab(tab); + else + removeCanBecomeStickyTab(tab); +} + +export function updateVirtualScrollRenderabilityIndexForTab(tab) { + if (tab.pinned || + (tab.hidden && + ((tab.url == 'about:firefoxview' && + tab.cookieStoreId == 'firefox-default') || + !configs.renderHiddenTabs)) || + tab.$TST.states.has(Constants.kTAB_STATE_COLLAPSED_DONE) || + (tabGroups.get(tab.groupId)?.$TST.states.has(Constants.kTAB_STATE_COLLAPSED_DONE) && + !tab.active)) + removeVirtualScrollRenderableTab(tab); + else + addVirtualScrollRenderableTab(tab); +} + +export function removeTabFromIndexes(tab) { + removeBundledActiveTab(tab); + removeLivingTab(tab); + removeControllableTab(tab); + removeRemovingTab(tab); + //removeRemovedTab(tab); + removeVisibleTab(tab); + removeExpandedTab(tab); + removeSelectedTab(tab); + removeHighlightedTab(tab); + removePinnedTab(tab); + removeUnpinnedTab(tab); + removeRootTab(tab); + removeGroupTab(tab); + removeToBeExpandedTab(tab); + removeSubtreeCollapsableTab(tab); + removeDuplicatingTab(tab); + removeDraggingTab(tab); + removeToBeGroupedTab(tab); + removeNativelyGroupedTab(tab); + removeLoadingTab(tab); + removeUnsynchronizedTab(tab); + //removeVirtualScrollRenderableTab(tab); + removeScrollPositionCalculationTargetTab(tab); + removeCanBecomeStickyTab(tab); +} + +function addTabToIndex(tab, indexes, windowId = null) { + if (!tab) + throw new Error(`TabsStore.addTabToIndex gets non-tab parameter!: ${JSON.stringify(tab)} : ${new Error().stack}`); + const tabs = indexes.get(windowId || tab.windowId); + if (tabs) + tabs.set(tab.id, tab); +} + +function removeTabFromIndex(tab, indexes, windowId = null) { + if (!tab) + throw new Error(`TabsStore.removeTabFromIndex gets non-tab parameter!: ${JSON.stringify(tab)} : ${new Error().stack}`); + const tabs = indexes.get(windowId || tab.windowId); + if (tabs) + tabs.delete(tab.id); +} + +export function addLivingTab(tab) { + addTabToIndex(tab, livingTabsInWindow); +} +export function removeLivingTab(tab) { + removeTabFromIndex(tab, livingTabsInWindow); +} + +export function addControllableTab(tab) { + addTabToIndex(tab, controllableTabsInWindow); +} +export function removeControllableTab(tab) { + removeTabFromIndex(tab, controllableTabsInWindow); +} + +export function addRemovingTab(tab) { + addTabToIndex(tab, removingTabsInWindow); + removeTabFromIndexes(tab); +} +export function removeRemovingTab(tab) { + removeTabFromIndex(tab, removingTabsInWindow); +} + +export function addRemovedTab(tab) { + addTabToIndex(tab, removedTabsInWindow); + setTimeout(removeRemovedTab, 100000, { + id: tab.id, + windowId: tab.windowId + }); +} +export function removeRemovedTab(tab) { + removeTabFromIndex(tab, removedTabsInWindow); +} + +export function addVisibleTab(tab) { + addTabToIndex(tab, visibleTabsInWindow); +} +export function removeVisibleTab(tab) { + removeTabFromIndex(tab, visibleTabsInWindow); +} + +export function addExpandedTab(tab) { + addTabToIndex(tab, expandedTabsInWindow); +} +export function removeExpandedTab(tab) { + removeTabFromIndex(tab, expandedTabsInWindow); +} + +export function addSelectedTab(tab) { + addTabToIndex(tab, selectedTabsInWindow); +} +export function removeSelectedTab(tab) { + removeTabFromIndex(tab, selectedTabsInWindow); +} + +export function addHighlightedTab(tab) { + addTabToIndex(tab, highlightedTabsInWindow); +} +export function removeHighlightedTab(tab) { + removeTabFromIndex(tab, highlightedTabsInWindow); +} + +export function addPinnedTab(tab) { + addTabToIndex(tab, pinnedTabsInWindow); +} +export function removePinnedTab(tab) { + removeTabFromIndex(tab, pinnedTabsInWindow); +} + +export function addUnpinnedTab(tab) { + addTabToIndex(tab, unpinnedTabsInWindow); +} +export function removeUnpinnedTab(tab) { + removeTabFromIndex(tab, unpinnedTabsInWindow); +} + +export function addRootTab(tab) { + addTabToIndex(tab, rootTabsInWindow); +} +export function removeRootTab(tab) { + removeTabFromIndex(tab, rootTabsInWindow); +} + +export function addGroupTab(tab) { + addTabToIndex(tab, groupTabsInWindow); +} +export function removeGroupTab(tab) { + removeTabFromIndex(tab, groupTabsInWindow); +} + +export function addToBeExpandedTab(tab) { + addTabToIndex(tab, toBeExpandedTabsInWindow); +} +export function removeToBeExpandedTab(tab) { + removeTabFromIndex(tab, toBeExpandedTabsInWindow); +} + +export function addSubtreeCollapsableTab(tab) { + addTabToIndex(tab, subtreeCollapsableTabsInWindow); +} +export function removeSubtreeCollapsableTab(tab) { + removeTabFromIndex(tab, subtreeCollapsableTabsInWindow); +} + +export function addDuplicatingTab(tab) { + addTabToIndex(tab, duplicatingTabsInWindow); +} +export function removeDuplicatingTab(tab) { + removeTabFromIndex(tab, duplicatingTabsInWindow); +} + +export function addDraggingTab(tab) { + addTabToIndex(tab, draggingTabsInWindow); +} +export function removeDraggingTab(tab) { + removeTabFromIndex(tab, draggingTabsInWindow); +} + +export function addToBeGroupedTab(tab) { + addTabToIndex(tab, toBeGroupedTabsInWindow); +} +export function removeToBeGroupedTab(tab) { + removeTabFromIndex(tab, toBeGroupedTabsInWindow); +} + +export function addNativelyGroupedTab(tab, windowId = null) { + addTabToIndex(tab, nativelyGroupedTabsInWindow, windowId); +} +export function removeNativelyGroupedTab(tab, windowId = null) { + removeTabFromIndex(tab, nativelyGroupedTabsInWindow, windowId); +} + +export function addLoadingTab(tab) { + addTabToIndex(tab, loadingTabsInWindow); +} +export function removeLoadingTab(tab) { + removeTabFromIndex(tab, loadingTabsInWindow); +} + +export function addUnsynchronizedTab(tab) { + addTabToIndex(tab, unsynchronizedTabsInWindow); +} +export function removeUnsynchronizedTab(tab) { + removeTabFromIndex(tab, unsynchronizedTabsInWindow); +} + +export function addBundledActiveTab(tab) { + addTabToIndex(tab, bundledActiveTabsInWindow); +} +export function removeBundledActiveTab(tab) { + removeTabFromIndex(tab, bundledActiveTabsInWindow); +} + +export function addVirtualScrollRenderableTab(tab) { + addTabToIndex(tab, virtualScrollRenderableTabsInWindow); +} +export function removeVirtualScrollRenderableTab(tab) { + removeTabFromIndex(tab, virtualScrollRenderableTabsInWindow); +} + +export function addScrollPositionCalculationTargetTab(tab) { + addTabToIndex(tab, scrollPositionCalculationTargetTabsInWindow); +} +export function removeScrollPositionCalculationTargetTab(tab) { + removeTabFromIndex(tab, scrollPositionCalculationTargetTabsInWindow); +} + +export function addCanBecomeStickyTab(tab) { + addTabToIndex(tab, canBecomeStickyTabsInWindow); +} +export function removeCanBecomeStickyTab(tab) { + removeTabFromIndex(tab, canBecomeStickyTabsInWindow); +} + + + +//=================================================================== +// Utilities +//=================================================================== + +export function assertValidTab(tab) { + if (tab?.$TST) + return; + const error = new Error('FATAL ERROR: invalid tab is given'); + console.log(error.message, tab, error.stack); + throw error; +} + +export function ensureLivingItem(tab) { + const isNativeTabGroup = tab?.type == 'group'; + if (!tab || + !tab.id || + !tab.$TST || + (!isNativeTabGroup && !tabs.has(tab.id)) || + tab.$TST.removing || + !windows.get(tab.windowId) || + (isNativeTabGroup && !windows.get(tab.windowId).tabGroups.has(tab.id))) + return null; + return tab; +} + + +//=================================================================== +// Logging +//=================================================================== + +browser.runtime.onMessage.addListener((message, _sender) => { + if (!message || + typeof message != 'object' || + message.type != Constants.kCOMMAND_REQUEST_QUERY_LOGS) + return; + + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_RESPONSE_QUERY_LOGS, + logs: JSON.parse(JSON.stringify(queryLogs)), + windowId: mTargetWindow || 'background' + }); +}); diff --git a/waterfox/browser/components/sidebar/common/tabs-update.js b/waterfox/browser/components/sidebar/common/tabs-update.js new file mode 100644 index 000000000000..8e8c9dd0c502 --- /dev/null +++ b/waterfox/browser/components/sidebar/common/tabs-update.js @@ -0,0 +1,444 @@ +/* ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is the Tree Style Tab. + * + * The Initial Developer of the Original Code is YUKI "Piro" Hiroshi. + * Portions created by the Initial Developer are Copyright (C) 2011-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): YUKI "Piro" Hiroshi + * wanabe + * Tetsuharu OHZEKI + * Xidorn Quan (Firefox 40+ support) + * lv7777 (https://github.com/lv7777) + * + * ***** END LICENSE BLOCK ******/ +'use strict'; + +import { + log as internalLogger, + dumpTab, + wait, + mapAndFilter +} from './common.js'; + +import * as Constants from './constants.js'; +import * as ContextualIdentities from './contextual-identities.js'; +import * as SidebarConnection from './sidebar-connection.js'; +import * as TabsStore from './tabs-store.js'; + +import { Tab } from './TreeItem.js'; + +function log(...args) { + internalLogger('common/tabs-update', ...args); +} + + +const mBufferedUpdates = new Map(); + +function getBufferedUpdate(tab) { + const update = mBufferedUpdates.get(tab.id) || { + windowId: tab.windowId, + tabId: tab.id, + attributes: { + updated: {}, + added: {}, + removed: new Set(), + }, + isGroupTab: false, + updatedTitle: undefined, + updatedLabel: undefined, + favIconUrl: undefined, + loadingState: undefined, + loadingStateReallyChanged: undefined, + pinned: undefined, + hidden: undefined, + groupId: undefined, + soundStateChanged: false, + }; + mBufferedUpdates.set(tab.id, update); + return update; +} + +function flushBufferedUpdates() { + if (!Constants.IS_BACKGROUND) { + mBufferedUpdates.clear(); + return; + } + + const triedAt = `${Date.now()}-${parseInt(Math.random() * 65000)}`; + flushBufferedUpdates.triedAt = triedAt; + (Constants.IS_BACKGROUND ? + setTimeout : // because window.requestAnimationFrame is decelerate for an invisible document. + window.requestAnimationFrame)(() => { + if (flushBufferedUpdates.triedAt != triedAt) + return; + for (const update of mBufferedUpdates.values()) { + // no need to notify attributes broadcasted via Tab.broadcastState() + delete update.attributes.updated.highlighted; + delete update.attributes.updated.hidden; + delete update.attributes.updated.pinned; + delete update.attributes.updated.audible; + delete update.attributes.updated.mutedInfo; + delete update.attributes.updated.sharingState; + delete update.attributes.updated.incognito; + delete update.attributes.updated.attention; + delete update.attributes.updated.discarded; + if (Object.keys(update.attributes.updated).length > 0 || + Object.keys(update.attributes.added).length > 0 || + update.attributes.removed.size > 0 || + update.soundStateChanged || + update.sharingStateChanged) + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_UPDATED, + windowId: update.windowId, + tabId: update.tabId, + updatedProperties: update.attributes.updated, + addedAttributes: update.attributes.added, + removedAttributes: [...update.attributes.removed], + soundStateChanged: update.soundStateChanged, + sharingStateChanged: update.sharingStateChanged, + }); + + // SidebarConnection.sendMessage() has its own bulk-send mechanism, + // so we don't need to bundle them like an array. + if (update.isGroupTab) + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_GROUP_TAB_DETECTED, + windowId: update.windowId, + tabId: update.tabId, + }); + if (update.updatedTitle !== undefined) + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TREE_ITEM_LABEL_UPDATED, + windowId: update.windowId, + tabId: update.tabId, + title: update.updatedTitle, + label: update.updatedLabel, + }); + if (update.favIconUrl !== undefined) + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_FAVICON_UPDATED, + windowId: update.windowId, + tabId: update.tabId, + favIconUrl: update.favIconUrl, + }); + if (update.loadingState !== undefined) + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_UPDATE_LOADING_STATE, + windowId: update.windowId, + tabId: update.tabId, + status: update.loadingState, + reallyChanged: update.loadingStateReallyChanged, + }); + if (update.pinned !== undefined) + SidebarConnection.sendMessage({ + type: update.pinned ? Constants.kCOMMAND_NOTIFY_TAB_PINNED : Constants.kCOMMAND_NOTIFY_TAB_UNPINNED, + windowId: update.windowId, + tabId: update.tabId, + }); + if (update.hidden !== undefined) + SidebarConnection.sendMessage({ + type: update.hidden ? Constants.kCOMMAND_NOTIFY_TAB_HIDDEN : Constants.kCOMMAND_NOTIFY_TAB_SHOWN, + windowId: update.windowId, + tabId: update.tabId, + }); + } + mBufferedUpdates.clear(); + }, 0); +} + +export function updateTab(tab, newState = {}, options = {}) { + const update = getBufferedUpdate(tab); + const oldState = options.old || {}; + + if ('url' in newState) { + tab.$TST.setAttribute(Constants.kCURRENT_URI, update.attributes.added[Constants.kCURRENT_URI] = newState.url); + update.attributes.removed.delete(Constants.kCURRENT_URI); + } + + if ('url' in newState && + newState.url.indexOf(Constants.kGROUP_TAB_URI) == 0) { + tab.$TST.addState(Constants.kTAB_STATE_GROUP_TAB, { permanently: true }); + update.isGroupTab = true; + Tab.onGroupTabDetected.dispatch(tab); + } + else if (tab.$TST.states.has(Constants.kTAB_STATE_GROUP_TAB) && + !tab.$TST.hasGroupTabURL) { + tab.$TST.removeState(Constants.kTAB_STATE_GROUP_TAB, { permanently: true }); + update.isGroupTab = false; + } + + if (options.forceApply || + ('title' in newState && + newState.title != oldState.title)) { + if (options.forceApply) { + tab.$TST.getPermanentStates().then(states => { + tab.$TST.toggleState( + Constants.kTAB_STATE_UNREAD, + (states.includes(Constants.kTAB_STATE_UNREAD) && + !tab.$TST.isGroupTab), + { permanently: true } + ); + }); + } + else if (tab.$TST.isGroupTab) { + tab.$TST.removeState(Constants.kTAB_STATE_UNREAD, { permanently: true }); + } + else if (!tab.active) { + tab.$TST.addState(Constants.kTAB_STATE_UNREAD, { permanently: true }); + } + tab.$TST.label = newState.title; + Tab.onLabelUpdated.dispatch(tab); + update.updatedTitle = tab.title; + update.updatedLabel = tab.$TST.label; + } + + const openerOfGroupTab = tab.$TST.isGroupTab && Tab.getOpenerFromGroupTab(tab); + if (openerOfGroupTab && + openerOfGroupTab.favIconUrl) { + update.favIconUrl = openerOfGroupTab.favIconUrl; + } + else if (options.forceApply || + 'favIconUrl' in newState) { + tab.$TST.setAttribute(Constants.kCURRENT_FAVICON_URI, update.attributes.added[Constants.kCURRENT_FAVICON_URI] = tab.favIconUrl); + update.attributes.removed.delete(Constants.kCURRENT_FAVICON_URI); + // "favIconUrl" will be "undefined" if the website has no favicon. + // Keys with "undefined" value will be removed from JSON-stringified result, + // so we need to use "null" instead of it. + // See also: https://github.com/piroor/treestyletab/issues/3515 + update.favIconUrl = tab.favIconUrl || null; + } + else if (tab.$TST.isGroupTab) { + // "about:ws-group" can set error icon for the favicon and + // reloading doesn't cloear that, so we need to clear favIconUrl manually. + tab.favIconUrl = null; + delete update.attributes.added[Constants.kCURRENT_FAVICON_URI]; + update.attributes.removed.add(Constants.kCURRENT_URI); + tab.$TST.removeAttribute(Constants.kCURRENT_FAVICON_URI); + update.favIconUrl = null; + } + + if ('status' in newState) { + const reallyChanged = !tab.$TST.states.has(newState.status); + const removed = newState.status == 'loading' ? 'complete' : 'loading'; + tab.$TST.removeState(removed); + tab.$TST.addState(newState.status); + if (!options.forceApply) { + update.loadingState = tab.status; + update.loadingStateReallyChanged = reallyChanged; + } + } + + if ((options.forceApply || + 'pinned' in newState) && + newState.pinned != tab.$TST.states.has(Constants.kTAB_STATE_PINNED)) { + if (newState.pinned) { + tab.$TST.addState(Constants.kTAB_STATE_PINNED); + tab.$TST.removeAttribute(Constants.kLEVEL); // don't indent pinned tabs! + delete update.attributes.added[Constants.kLEVEL]; + update.attributes.removed.add(Constants.kLEVEL); + Tab.onPinned.dispatch(tab); + update.pinned = true; + } + else { + tab.$TST.removeState(Constants.kTAB_STATE_PINNED); + Tab.onUnpinned.dispatch(tab); + update.pinned = false; + } + } + + if (options.forceApply || + 'audible' in newState) + tab.$TST.toggleState(Constants.kTAB_STATE_AUDIBLE, newState.audible); + + let soundStateChanged = false; + + if (options.forceApply || + 'mutedInfo' in newState) { + soundStateChanged = true; + const muted = newState.mutedInfo?.muted; + tab.$TST.toggleState(Constants.kTAB_STATE_MUTED, muted, newState.audible); + Tab.onMutedStateChanged.dispatch(tab, muted); + } + + if (options.forceApply || + soundStateChanged || + 'audible' in newState) { + soundStateChanged = true; + tab.$TST.toggleState( + Constants.kTAB_STATE_SOUND_PLAYING, + (tab.audible && + !tab.mutedInfo.muted) + ); + } + + if (soundStateChanged) { + const parent = tab.$TST.parent; + if (parent) + parent.$TST.inheritSoundStateFromChildren(); + } + + let sharingStateChanged = false; + if (options.forceApply || + 'sharingState' in newState) { + sharingStateChanged = true; + const sharingCamera = !!newState.sharingState?.camera; + const sharingMicrophone = !!newState.sharingState?.microphone; + const sharingScreen = !!newState.sharingState?.screen; + tab.$TST.toggleState(Constants.kTAB_STATE_SHARING_CAMERA, sharingCamera); + tab.$TST.toggleState(Constants.kTAB_STATE_SHARING_MICROPHONE, sharingMicrophone); + tab.$TST.toggleState(Constants.kTAB_STATE_SHARING_SCREEN, sharingScreen); + Tab.onSharingStateChanged.dispatch(tab, { + camera: sharingCamera, + microphone: sharingMicrophone, + screen: sharingScreen, + }); + const parent = tab.$TST.parent; + if (parent) + parent.$TST.inheritSharingStateFromChildren(); + } + + if (options.forceApply || + 'cookieStoreId' in newState) { + for (const state of tab.$TST.states) { + if (String(state).startsWith('contextual-identity-')) + tab.$TST.removeState(state); + } + if (newState.cookieStoreId) { + const state = `contextual-identity-${newState.cookieStoreId}`; + tab.$TST.addState(state); + const identity = ContextualIdentities.get(newState.cookieStoreId); + if (identity) + tab.$TST.setAttribute(Constants.kCONTEXTUAL_IDENTITY_NAME, identity.name); + else + tab.$TST.removeAttribute(Constants.kCONTEXTUAL_IDENTITY_NAME); + } + else { + tab.$TST.removeAttribute(Constants.kCONTEXTUAL_IDENTITY_NAME); + } + } + + if (options.forceApply || + 'incognito' in newState) + tab.$TST.toggleState(Constants.kTAB_STATE_PRIVATE_BROWSING, newState.incognito); + + if (options.forceApply || + 'hidden' in newState) { + if (newState.hidden) { + if (!tab.$TST.states.has(Constants.kTAB_STATE_HIDDEN)) { + tab.$TST.addState(Constants.kTAB_STATE_HIDDEN); + Tab.onHidden.dispatch(tab); + update.hidden = true; + } + } + else if (tab.$TST.states.has(Constants.kTAB_STATE_HIDDEN)) { + tab.$TST.removeState(Constants.kTAB_STATE_HIDDEN); + Tab.onShown.dispatch(tab); + update.hidden = false; + } + } + + if (options.forceApply || + 'highlighted' in newState) + tab.$TST.toggleState(Constants.kTAB_STATE_HIGHLIGHTED, newState.highlighted); + + if (options.forceApply || + 'attention' in newState) + tab.$TST.toggleState(Constants.kTAB_STATE_ATTENTION, newState.attention); + + if (options.forceApply || + 'discarded' in newState) { + wait(0).then(() => { + // Don't set this class immediately, because we need to know + // the newly active tab *was* discarded on onTabClosed handler. + tab.$TST.toggleState(Constants.kTAB_STATE_DISCARDED, newState.discarded); + }); + } + + if (options.forceApply || + 'groupId' in newState) { + tab.$TST.onNativeGroupModified(oldState.groupId); + update.attributes.added[Constants.kGROUP_ID] = newState.groupId; + } + + update.soundStateChanged = update.soundStateChanged || soundStateChanged; + update.sharingStateChanged = update.sharingStateChanged || sharingStateChanged; + update.attributes.updated = { + ...update.attributes.updated, + ...(newState?.$TST?.sanitized || newState), + }; + flushBufferedUpdates(); + + tab.$TST.invalidateCache(); +} + +export async function updateTabsHighlighted(highlightInfo) { + if (Tab.needToWaitTracked(highlightInfo.windowId)) + await Tab.waitUntilTrackedAll(highlightInfo.windowId); + const win = TabsStore.windows.get(highlightInfo.windowId); + if (!win) + return; + + //const startAt = Date.now(); + + const tabIds = highlightInfo.tabIds; // new Set(highlightInfo.tabIds); + const toBeUnhighlightedTabs = Tab.getHighlightedTabs(highlightInfo.windowId, { + ordered: false, + '!id': tabIds + }); + const alreadyHighlightedTabs = TabsStore.highlightedTabsInWindow.get(highlightInfo.windowId); + const toBeHighlightedTabs = mapAndFilter(tabIds, id => { + const tab = win.tabs.get(id); + return tab && !alreadyHighlightedTabs.has(tab.id) && tab || undefined; + }); + + //console.log(`updateTabsHighlighted: ${Date.now() - startAt}ms`, { toBeHighlightedTabs, toBeUnhighlightedTabs}); + + const inheritToCollapsedDescendants = !!highlightInfo.inheritToCollapsedDescendants; + //log('updateTabsHighlighted ', { toBeHighlightedTabs, toBeUnhighlightedTabs}); + for (const tab of toBeUnhighlightedTabs) { + TabsStore.removeHighlightedTab(tab); + updateTabHighlighted(tab, false, { inheritToCollapsedDescendants }); + } + for (const tab of toBeHighlightedTabs) { + TabsStore.addHighlightedTab(tab); + updateTabHighlighted(tab, true, { inheritToCollapsedDescendants }); + } +} +async function updateTabHighlighted(tab, highlighted, { inheritToCollapsedDescendants } = {}) { + log(`highlighted status of ${dumpTab(tab)}: `, { old: tab.highlighted, new: highlighted }); + //if (tab.highlighted == highlighted) + // return false; + tab.$TST.toggleState(Constants.kTAB_STATE_HIGHLIGHTED, highlighted); + tab.highlighted = highlighted; + const win = TabsStore.windows.get(tab.windowId); + const inheritHighlighted = !win.tabsToBeHighlightedAlone.has(tab.id); + if (!inheritHighlighted) + win.tabsToBeHighlightedAlone.delete(tab.id); + updateTab(tab, { highlighted }); + Tab.onUpdated.dispatch(tab, { highlighted }, { + inheritHighlighted: inheritToCollapsedDescendants && inheritHighlighted, + }); + return true; +} + + +export async function completeLoadingTabs(windowId) { + const completedTabs = new Set((await browser.tabs.query({ windowId, status: 'complete' })).map(tab => tab.id)); + for (const tab of Tab.getLoadingTabs(windowId, { ordered: false, iterator: true })) { + if (completedTabs.has(tab.id)) + updateTab(tab, { status: 'complete' }); + } +} diff --git a/waterfox/browser/components/sidebar/common/tree-behavior.js b/waterfox/browser/components/sidebar/common/tree-behavior.js new file mode 100644 index 000000000000..49a9a360dae2 --- /dev/null +++ b/waterfox/browser/components/sidebar/common/tree-behavior.js @@ -0,0 +1,417 @@ +/* +# 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'; + +import { + log as internalLogger, + configs +} from './common.js'; +import * as Constants from './constants.js'; +import * as SidebarConnection from './sidebar-connection.js'; + +import { TreeItem } from './TreeItem.js'; + +function log(...args) { + internalLogger('common/tree-behavior', ...args); +} + +export function getParentTabOperationBehavior(tab, { context, byInternalOperation, preventEntireTreeBehavior, parent, windowId } = {}) { + const sidebarVisible = SidebarConnection.isInitialized() ? ((windowId || tab) && SidebarConnection.isOpen(windowId || tab.windowId)) : true; + log('getParentTabOperationBehavior ', tab, { byInternalOperation, preventEntireTreeBehavior, parent, sidebarVisible /*, stack: configs.debug && new Error().stack */ }); + + // strategy: https://github.com/piroor/treestyletab/issues/2860#issuecomment-820622273 + let behavior; + switch (configs.parentTabOperationBehaviorMode) { + case Constants.kPARENT_TAB_OPERATION_BEHAVIOR_MODE_CONSISTENT: + log(' => kPARENT_TAB_OPERATION_BEHAVIOR_MODE_CONSISTENT'); + if (context == Constants.kPARENT_TAB_OPERATION_CONTEXT_MOVE) { + behavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE; + } + else { + behavior = tab.$TST.subtreeCollapsed ? + Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE : + configs.closeParentBehavior_insideSidebar_expanded; + } + break; + default: + case Constants.kPARENT_TAB_OPERATION_BEHAVIOR_MODE_PARALLEL: + log(' => kPARENT_TAB_OPERATION_BEHAVIOR_MODE_PARALLEL'); + if (context == Constants.kPARENT_TAB_OPERATION_CONTEXT_MOVE) { + behavior = byInternalOperation ? + Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE : + configs.moveParentBehavior_outsideSidebar_expanded; + } + else { + behavior = byInternalOperation ? + (tab.$TST.subtreeCollapsed ? + Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE : + configs.closeParentBehavior_insideSidebar_expanded) : + configs.closeParentBehavior_outsideSidebar_expanded; + } + break; + case Constants.kPARENT_TAB_OPERATION_BEHAVIOR_MODE_CUSTOM: // kPARENT_TAB_BEHAVIOR_ONLY_ON_SIDEBAR + log(' => kPARENT_TAB_OPERATION_BEHAVIOR_MODE_CUSTOM'); + if (context == Constants.kPARENT_TAB_OPERATION_CONTEXT_MOVE) { + behavior = byInternalOperation ? + Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE : + sidebarVisible ? + (tab.$TST.subtreeCollapsed ? + configs.moveParentBehavior_outsideSidebar_collapsed : + configs.moveParentBehavior_outsideSidebar_expanded) : + (tab.$TST.subtreeCollapsed ? + configs.moveParentBehavior_noSidebar_collapsed : + configs.moveParentBehavior_noSidebar_expanded); + } + else { + behavior = byInternalOperation ? + (tab.$TST.subtreeCollapsed ? + Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE : + configs.closeParentBehavior_insideSidebar_expanded) : + sidebarVisible ? + (tab.$TST.subtreeCollapsed ? + configs.closeParentBehavior_outsideSidebar_collapsed : + configs.closeParentBehavior_outsideSidebar_expanded) : + (tab.$TST.subtreeCollapsed ? + configs.closeParentBehavior_noSidebar_collapsed : + configs.closeParentBehavior_noSidebar_expanded); + } + break; + } + const parentTab = parent || tab.$TST.parent; + + log(' => behavior: ', behavior); + + const replacedParentCount = tab?.$TST?.replacedParentGroupTabCount; + if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_REPLACE_WITH_GROUP_TAB && + configs.closeParentBehavior_replaceWithGroup_thresholdToPrevent >= 0 && + replacedParentCount && + replacedParentCount >= configs.closeParentBehavior_replaceWithGroup_thresholdToPrevent) { + behavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY; + log(' => the group tab is already replaced parent, fallback to kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY'); + } + + if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE && + preventEntireTreeBehavior) { + behavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY; + log(' => preventEntireTreeBehavior behavior, fallback to kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY'); + } + + if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_INTELLIGENTLY) { + behavior = parentTab ? Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN : Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD; + log(' => intelligent behavior: ', behavior); + } + + // Promote all children to upper level, if this is the last child of the parent. + // This is similar to "taking by representation". + if (behavior == Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_FIRST_CHILD && + parentTab && + parentTab.$TST.childIds.length == 1 && + configs.promoteAllChildrenWhenClosedParentIsLastChild) { + behavior = Constants.kPARENT_TAB_OPERATION_BEHAVIOR_PROMOTE_ALL_CHILDREN; + log(' => blast child ehavior: ', behavior); + } + + return behavior; +} + +export function getClosingTabsFromParent(tab, removeInfo = {}) { + log('getClosingTabsFromParent: ', tab, removeInfo); + if (tab?.type == TreeItem.TYPE_GROUP) { + return tab.$TST.members; + } + const closeParentBehavior = getParentTabOperationBehavior(tab, { + ...removeInfo, + context: Constants.kPARENT_TAB_OPERATION_CONTEXT_CLOSE, + }); + log('getClosingTabsFromParent: closeParentBehavior ', closeParentBehavior); + if (closeParentBehavior != Constants.kPARENT_TAB_OPERATION_BEHAVIOR_ENTIRE_TREE) + return [tab]; + return [tab].concat(tab.$TST.descendants); +} + +export function calculateReferenceItemsFromInsertionPosition( + item, + { context, insertBefore, insertAfter } = {} +) { + let firstItem = (Array.isArray(item) ? item[0] : item) || item; + let lastItem = (Array.isArray(item) ? item[item.length - 1] : item) || item; + firstItem = firstItem?.$TST?.nativeTabGroup || firstItem; + lastItem = lastItem?.$TST?.nativeTabGroup?.collapsed && lastItem?.$TST?.nativeTabGroup || lastItem; + log('calculateReferenceItemsFromInsertionPosition ', { + firstItem: firstItem?.id, + lastItem: lastItem?.id, + insertBefore: insertBefore?.id, + insertAfter : insertAfter?.id + }); + if (insertBefore) { + /* strategy for moved case + +------------------ CASE 1 --------------------------- + | <= detach from parent, and move + |[TARGET ] + +------------------ CASE 2 --------------------------- + | [ ] + | <= attach to the parent of the target, and move + |[TARGET ] + +------------------ CASE 3 --------------------------- + |[ ] + | <= attach to the parent of the target, and move + |[TARGET ] + +------------------ CASE 4 --------------------------- + |[ ] + | <= attach to the parent of the target (previous item), and move + | [TARGET] + +----------------------------------------------------- + */ + /* strategy for shown case + +------------------ CASE 5 --------------------------- + | <= detach from parent, and move + |[TARGET ] + +------------------ CASE 6 --------------------------- + | [ ] + | <= if the inserted item has a parent and it is not the parent of the target, attach to the parent of the target. Otherwise keep inserted as a root. + |[TARGET ] + +------------------ CASE 7 --------------------------- + |[ ] + | <= attach to the parent of the target, and move + |[TARGET ] + +------------------ CASE 8 --------------------------- + |[ ] + | <= attach to the parent of the target (previous item), and move + | [TARGET] + +----------------------------------------------------- + */ + if (insertBefore.type == TreeItem.TYPE_GROUP) { + log('calculateReferenceItemsFromInsertionPosition: from insertBefore, special case for a group item'); + return { + insertBefore, + }; + } + let prevItem = insertBefore && + (configs.fixupTreeOnTabVisibilityChanged ? + insertBefore.$TST.nearestVisiblePrecedingTab : + insertBefore.$TST.unsafeNearestExpandedPrecedingTab); + if (prevItem == lastItem) // failsafe + prevItem = !firstItem ? null : + configs.fixupTreeOnTabVisibilityChanged ? + firstItem?.$TST.nearestVisiblePrecedingTab : + firstItem?.$TST.unsafeNearestExpandedPrecedingTab; + if (!prevItem) { + log('calculateReferenceItemsFromInsertionPosition: from insertBefore, CASE 1/5'); + // allow to move pinned item to beside of another pinned item + if (!firstItem || + !!firstItem.pinned == !!insertBefore?.pinned) { + return { + insertBefore + }; + } + else { + return {}; + } + } + else { + const prevLevel = Number(prevItem?.$TST?.getAttribute(Constants.kLEVEL) || 0); + const targetLevel = Number(insertBefore?.$TST?.getAttribute(Constants.kLEVEL) || 0); + let parent = null; + if (!firstItem || !firstItem.pinned) { + if (prevLevel < targetLevel) { + if (context == Constants.kINSERTION_CONTEXT_MOVED) { + log('calculateReferenceItemsFromInsertionPosition: from insertBefore, CASE 4, prevItem = ', prevItem); + parent = prevItem; + } + else { + log('calculateReferenceItemsFromInsertionPosition: from insertBefore, CASE 8, prevItem = ', prevItem); + parent = (firstItem?.$TST?.parent != prevItem) ? prevItem : null; + } + } + else { + const possibleParent = insertBefore?.$TST?.parent; + if (context == Constants.kINSERTION_CONTEXT_MOVED || prevLevel == targetLevel) { + log('calculateReferenceItemsFromInsertionPosition: from insertBefore, CASE 2/3/7'); + parent = possibleParent; + } + else { + log('calculateReferenceItemsFromInsertionPosition: from insertBefore, CASE 6'); + parent = firstItem?.$TST?.parent != possibleParent && possibleParent || firstItem?.$TST?.parent; + } + } + } + const result = { + parent, + insertAfter: prevItem, + insertBefore + }; + log(' => ', result); + return result; + } + } + if (insertAfter) { + /* strategy for moved case + +------------------ CASE 1 --------------------------- + |[TARGET ] + | <= if the target has a parent, attach to it and and move + +------------------ CASE 2 --------------------------- + | [TARGET] + | <= attach to the parent of the target, and move + |[ ] + +------------------ CASE 3 --------------------------- + |[TARGET ] + | <= attach to the parent of the target, and move + |[ ] + +------------------ CASE 4 --------------------------- + |[TARGET ] + | <= attach to the target, and move + | [ ] + +----------------------------------------------------- + */ + /* strategy for shown case + +------------------ CASE 5 --------------------------- + |[TARGET ] + | <= if the inserted item has a parent, detach. Otherwise keep inserted as a root. + +------------------ CASE 6 --------------------------- + | [TARGET] + | <= if the inserted item has a parent and it is not the parent of the next item, attach to the parent of the target. Otherwise attach to the parent of the next item. + |[ ] + +------------------ CASE 7 --------------------------- + |[TARGET ] + | <= attach to the parent of the target, and move + |[ ] + +------------------ CASE 8 --------------------------- + |[TARGET ] + | <= attach to the target, and move + | [ ] + +----------------------------------------------------- + */ + // We need to refer unsafeNearestExpandedFollowingTab instead of a visible item, to avoid + // placing the item after hidden items (it is too far from the target). + let unsafeNextItem = insertAfter?.$TST?.unsafeNearestExpandedFollowingTab; + if (firstItem && unsafeNextItem == firstItem) // failsafe + unsafeNextItem = lastItem?.$TST?.unsafeNearestExpandedFollowingTab; + let nextItem = insertAfter && + (configs.fixupTreeOnTabVisibilityChanged ? + insertAfter.$TST?.nearestVisibleFollowingTab : + unsafeNextItem); + if (firstItem && nextItem == firstItem) // failsafe + nextItem = configs.fixupTreeOnTabVisibilityChanged ? + lastItem?.$TST?.nearestVisibleFollowingTab : + unsafeNextItem; + if (!nextItem) { + let result; + if (context == Constants.kINSERTION_CONTEXT_MOVED) { + log('calculateReferenceItemsFromInsertionPosition: from insertAfter, CASE 1'); + result = { + parent: insertAfter?.$TST?.parent, + insertBefore: unsafeNextItem, + insertAfter + }; + } + else { + log('calculateReferenceItemsFromInsertionPosition: from insertAfter, CASE 5'); + result = { + parent: firstItem?.$TST?.parent && insertAfter?.$TST?.parent, + insertBefore: unsafeNextItem, + insertAfter + }; + } + log(' => ', result); + return result; + } + else { + const targetLevel = Number(insertAfter?.$TST?.getAttribute(Constants.kLEVEL) || 0); + const nextLevel = Number(nextItem?.$TST?.getAttribute(Constants.kLEVEL) || 0); + let parent = null; + if (!firstItem || !firstItem.pinned) { + if (targetLevel < nextLevel) { + log('calculateReferenceItemsFromInsertionPosition: from insertAfter, CASE 4/8'); + parent = insertAfter; + } + else { + const possibleParent = insertAfter?.$TST?.parent; + if (context == Constants.kINSERTION_CONTEXT_MOVED || targetLevel == nextLevel) { + log('calculateReferenceItemsFromInsertionPosition: from insertAfter, CASE 2/3/7'); + parent = possibleParent; + } + else { + log('calculateReferenceItemsFromInsertionPosition: from insertAfter, CASE 6'); + parent = firstItem?.$TST?.parent != possibleParent && possibleParent || firstItem?.$TST.parent; + } + } + } + const result = { + parent, + insertBefore: unsafeNextItem || nextItem, + insertAfter + }; + log(' => ', result); + return result; + } + } + throw new Error('calculateReferenceItemsFromInsertionPosition requires one of insertBefore or insertAfter parameter!'); +} + + +export const STRUCTURE_NO_PARENT = -1; +export const STRUCTURE_KEEP_PARENT = -2; + +export function getTreeStructureFromTabs(tabs, { full, keepParentOfRootTabs } = {}) { + if (!tabs || !tabs.length) + return []; + + /* this returns... + [A] => STRUCTURE_NO_PARENT (parent is not in this tree) + [B] => 0 (parent is 1st item in this tree) + [C] => 0 (parent is 1st item in this tree) + [D] => 2 (parent is 2nd in this tree) + [E] => STRUCTURE_NO_PARENT (parent is not in this tree, and this creates another tree) + [F] => 0 (parent is 1st item in this another tree) + */ + const tabIds = tabs.map(tab => tab.id); + return cleanUpTreeStructureArray( + tabs.map((tab, index) => { + const parentId = tab.$TST.parentId; + const indexInGivenTabs = parent ? tabIds.indexOf(parentId) : STRUCTURE_NO_PARENT ; + return indexInGivenTabs >= index ? STRUCTURE_NO_PARENT : indexInGivenTabs ; + }), + STRUCTURE_NO_PARENT + ).map((parentIndex, index) => { + if (parentIndex == STRUCTURE_NO_PARENT && + keepParentOfRootTabs) + parentIndex = STRUCTURE_KEEP_PARENT; + const tab = tabs[index]; + const item = { + id: tab.$TST.uniqueId.id, + parent: parentIndex, + collapsed: tab.$TST.subtreeCollapsed + }; + if (full) { + item.title = tab.title; + item.url = tab.url; + item.pinned = tab.pinned; + item.originalId = tab.id; + } + return item; + }); +} +function cleanUpTreeStructureArray(treeStructure, defaultParent) { + let offset = 0; + treeStructure = treeStructure + .map((position, index) => { + return (position == index) ? STRUCTURE_NO_PARENT : position ; + }) + .map((position, index) => { + if (position == STRUCTURE_NO_PARENT) { + offset = index; + return position; + } + return position - offset; + }); + + /* The final step, this validates all of values. + Smaller than STRUCTURE_NO_PARENT is invalid, so it becomes to STRUCTURE_NO_PARENT. */ + treeStructure = treeStructure.map(index => { + return index < STRUCTURE_NO_PARENT ? defaultParent : index ; + }); + return treeStructure; +} diff --git a/waterfox/browser/components/sidebar/common/tst-api.js b/waterfox/browser/components/sidebar/common/tst-api.js new file mode 100644 index 000000000000..2deff7b6d19a --- /dev/null +++ b/waterfox/browser/components/sidebar/common/tst-api.js @@ -0,0 +1,1502 @@ +/* ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is the Tree Style Tab. + * + * The Initial Developer of the Original Code is YUKI "Piro" Hiroshi. + * Portions created by the Initial Developer are Copyright (C) 2011-2024 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): YUKI "Piro" Hiroshi + * wanabe + * Tetsuharu OHZEKI + * Xidorn Quan (Firefox 40+ support) + * lv7777 (https://github.com/lv7777) + * + * ***** END LICENSE BLOCK ******/ +'use strict'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; + +import { + log as internalLogger, + wait, + configs, + isLinux, +} from './common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from './constants.js'; +import * as SidebarConnection from './sidebar-connection.js'; +import * as TabsStore from './tabs-store.js'; + +import { + Tab, + kPERMISSION_INCOGNITO, + kPERMISSIONS_ALL, +} from './TreeItem.js'; + +function log(...args) { + internalLogger('common/tst-api', ...args); +} + +export const onInitialized = new EventListenerManager(); +export const onRegistered = new EventListenerManager(); +export const onUnregistered = new EventListenerManager(); +export const onMessageExternal = { + $listeners: new Set(), + addListener(listener) { + this.$listeners.add(listener); + }, + removeListener(listener) { + this.$listeners.delete(listener); + }, + dispatch(...args) { + return Array.from(this.$listeners, listener => listener(...args)); + } +}; + +export const kREGISTER_SELF = 'register-self'; +export const kUNREGISTER_SELF = 'unregister-self'; +export const kGET_VERSION = 'get-version'; +export const kWAIT_FOR_SHUTDOWN = 'wait-for-shutdown'; +export const kPING = 'ping'; +export const kNOTIFY_READY = 'ready'; +export const kNOTIFY_SHUTDOWN = 'shutdown'; // defined but not notified for now. +export const kNOTIFY_SIDEBAR_SHOW = 'sidebar-show'; +export const kNOTIFY_SIDEBAR_HIDE = 'sidebar-hide'; +export const kNOTIFY_TABS_RENDERED = 'tabs-rendered'; +export const kNOTIFY_TABS_UNRENDERED = 'tabs-unrendered'; +export const kNOTIFY_TAB_STICKY_STATE_CHANGED = 'tab-sticky-state-changed'; +export const kNOTIFY_TAB_CLICKED = 'tab-clicked'; // for backward compatibility +export const kNOTIFY_TAB_DBLCLICKED = 'tab-dblclicked'; +export const kNOTIFY_TAB_MOUSEDOWN = 'tab-mousedown'; +export const kNOTIFY_TAB_MOUSEUP = 'tab-mouseup'; +export const kNOTIFY_TABBAR_CLICKED = 'tabbar-clicked'; // for backward compatibility +export const kNOTIFY_TABBAR_MOUSEDOWN = 'tabbar-mousedown'; +export const kNOTIFY_TABBAR_MOUSEUP = 'tabbar-mouseup'; +export const kNOTIFY_EXTRA_CONTENTS_CLICKED = 'extra-contents-clicked'; +export const kNOTIFY_EXTRA_CONTENTS_DBLCLICKED = 'extra-contents-dblclicked'; +export const kNOTIFY_EXTRA_CONTENTS_MOUSEDOWN = 'extra-contents-mousedown'; +export const kNOTIFY_EXTRA_CONTENTS_MOUSEUP = 'extra-contents-mouseup'; +export const kNOTIFY_EXTRA_CONTENTS_KEYDOWN = 'extra-contents-keydown'; +export const kNOTIFY_EXTRA_CONTENTS_KEYUP = 'extra-contents-keyup'; +export const kNOTIFY_EXTRA_CONTENTS_INPUT = 'extra-contents-input'; +export const kNOTIFY_EXTRA_CONTENTS_CHANGE = 'extra-contents-change'; +export const kNOTIFY_EXTRA_CONTENTS_COMPOSITIONSTART = 'extra-contents-compositionstart'; +export const kNOTIFY_EXTRA_CONTENTS_COMPOSITIONUPDATE = 'extra-contents-compositionupdate'; +export const kNOTIFY_EXTRA_CONTENTS_COMPOSITIONEND = 'extra-contents-compositionend'; +export const kNOTIFY_EXTRA_CONTENTS_FOCUS = 'extra-contents-focus'; +export const kNOTIFY_EXTRA_CONTENTS_BLUR = 'extra-contents-blur'; +export const kNOTIFY_TABBAR_OVERFLOW = 'tabbar-overflow'; +export const kNOTIFY_TABBAR_UNDERFLOW = 'tabbar-underflow'; +export const kNOTIFY_NEW_TAB_BUTTON_CLICKED = 'new-tab-button-clicked'; +export const kNOTIFY_NEW_TAB_BUTTON_MOUSEDOWN = 'new-tab-button-mousedown'; +export const kNOTIFY_NEW_TAB_BUTTON_MOUSEUP = 'new-tab-button-mouseup'; +export const kNOTIFY_TAB_MOUSEMOVE = 'tab-mousemove'; +export const kNOTIFY_TAB_MOUSEOVER = 'tab-mouseover'; +export const kNOTIFY_TAB_MOUSEOUT = 'tab-mouseout'; +export const kNOTIFY_TAB_DRAGREADY = 'tab-dragready'; +export const kNOTIFY_TAB_DRAGCANCEL = 'tab-dragcancel'; +export const kNOTIFY_TAB_DRAGSTART = 'tab-dragstart'; +export const kNOTIFY_TAB_DRAGENTER = 'tab-dragenter'; +export const kNOTIFY_TAB_DRAGEXIT = 'tab-dragexit'; +export const kNOTIFY_TAB_DRAGEND = 'tab-dragend'; +export const kNOTIFY_TREE_ATTACHED = 'tree-attached'; +export const kNOTIFY_TREE_DETACHED = 'tree-detached'; +export const kNOTIFY_TREE_COLLAPSED_STATE_CHANGED = 'tree-collapsed-state-changed'; +export const kNOTIFY_NATIVE_TAB_DRAGSTART = 'native-tab-dragstart'; +export const kNOTIFY_NATIVE_TAB_DRAGEND = 'native-tab-dragend'; +export const kNOTIFY_PERMISSIONS_CHANGED = 'permissions-changed'; +export const kNOTIFY_NEW_TAB_PROCESSED = 'new-tab-processed'; +export const kSTART_CUSTOM_DRAG = 'start-custom-drag'; +export const kNOTIFY_TRY_MOVE_FOCUS_FROM_COLLAPSING_TREE = 'try-move-focus-from-collapsing-tree'; +export const kNOTIFY_TRY_REDIRECT_FOCUS_FROM_COLLAPSED_TAB = 'try-redirect-focus-from-collaped-tab'; +export const kNOTIFY_TRY_EXPAND_TREE_FROM_FOCUSED_PARENT = 'try-expand-tree-from-focused-parent'; +export const kNOTIFY_TRY_EXPAND_TREE_FROM_FOCUSED_BUNDLED_PARENT = 'try-expand-tree-from-focused-bundled-parent'; +export const kNOTIFY_TRY_EXPAND_TREE_FROM_ATTACHED_CHILD = 'try-expand-tree-from-attached-child'; +export const kNOTIFY_TRY_EXPAND_TREE_FROM_FOCUSED_COLLAPSED_TAB = 'try-expand-tree-from-focused-collapsed-tab'; +export const kNOTIFY_TRY_EXPAND_TREE_FROM_LONG_PRESS_CTRL_KEY = 'try-expand-tree-from-long-press-ctrl-key'; +export const kNOTIFY_TRY_EXPAND_TREE_FROM_END_TAB_SWITCH = 'try-expand-tree-from-end-tab-switch'; +export const kNOTIFY_TRY_EXPAND_TREE_FROM_EXPAND_COMMAND = 'try-expand-tree-from-expand-command'; +export const kNOTIFY_TRY_EXPAND_TREE_FROM_EXPAND_ALL_COMMAND = 'try-expand-tree-from-expand-all-command'; +export const kNOTIFY_TRY_COLLAPSE_TREE_FROM_OTHER_EXPANSION = 'try-collapse-tree-from-other-expansion'; +export const kNOTIFY_TRY_COLLAPSE_TREE_FROM_COLLAPSE_COMMAND = 'try-collapse-tree-from-collapse-command'; +export const kNOTIFY_TRY_COLLAPSE_TREE_FROM_COLLAPSE_ALL_COMMAND = 'try-collapse-tree-from-collapse-all-command'; +export const kNOTIFY_TRY_FIXUP_TREE_ON_TAB_MOVED = 'try-fixup-tree-on-tab-moved'; +export const kNOTIFY_TRY_HANDLE_NEWTAB = 'try-handle-newtab'; +export const kNOTIFY_TRY_SCROLL_TO_ACTIVATED_TAB = 'try-scroll-to-activated-tab'; +export const kGET_TREE = 'get-tree'; +export const kGET_LIGHT_TREE = 'get-light-tree'; +export const kATTACH = 'attach'; +export const kDETACH = 'detach'; +export const kINDENT = 'indent'; +export const kDEMOTE = 'demote'; +export const kOUTDENT = 'outdent'; +export const kPROMOTE = 'promote'; +export const kMOVE_UP = 'move-up'; +export const kMOVE_TO_START = 'move-to-start'; +export const kMOVE_DOWN = 'move-down'; +export const kMOVE_TO_END = 'move-to-end'; +export const kMOVE_BEFORE = 'move-before'; +export const kMOVE_AFTER = 'move-after'; +export const kFOCUS = 'focus'; +export const kCREATE = 'create'; +export const kDUPLICATE = 'duplicate'; +export const kGROUP_TABS = 'group-tabs'; +export const kOPEN_IN_NEW_WINDOW = 'open-in-new-window'; +export const kREOPEN_IN_CONTAINER = 'reopen-in-container'; +export const kGET_TREE_STRUCTURE = 'get-tree-structure'; +export const kSET_TREE_STRUCTURE = 'set-tree-structure'; +export const kSTICK_TAB = 'stick-tab'; +export const kUNSTICK_TAB = 'unstick-tab'; +export const kTOGGLE_STICKY_STATE = 'toggle-sticky-state'; +export const kREGISTER_AUTO_STICKY_STATES = 'register-auto-sticky-states'; +export const kUNREGISTER_AUTO_STICKY_STATES = 'unregister-auto-sticky-states'; +export const kCOLLAPSE_TREE = 'collapse-tree'; +export const kEXPAND_TREE = 'expand-tree'; +export const kTOGGLE_TREE_COLLAPSED = 'toggle-tree-collapsed'; +export const kADD_TAB_STATE = 'add-tab-state'; +export const kREMOVE_TAB_STATE = 'remove-tab-state'; +export const kSCROLL = 'scroll'; +export const kSTOP_SCROLL = 'stop-scroll'; +export const kSCROLL_LOCK = 'scroll-lock'; +export const kSCROLL_UNLOCK = 'scroll-unlock'; +export const kNOTIFY_SCROLLED = 'scrolled'; +export const kBLOCK_GROUPING = 'block-grouping'; +export const kUNBLOCK_GROUPING = 'unblock-grouping'; +export const kSET_TOOLTIP_TEXT = 'set-tooltip-text'; +export const kCLEAR_TOOLTIP_TEXT = 'clear-tooltip-text'; +export const kGRANT_TO_REMOVE_TABS = 'grant-to-remove-tabs'; +export const kOPEN_ALL_BOOKMARKS_WITH_STRUCTURE = 'open-all-bookmarks-with-structure'; +export const kSET_EXTRA_CONTENTS = 'set-extra-contents'; +export const kCLEAR_EXTRA_CONTENTS = 'clear-extra-contents'; +export const kCLEAR_ALL_EXTRA_CONTENTS = 'clear-all-extra-contents'; +export const kSET_EXTRA_TAB_CONTENTS = 'set-extra-tab-contents'; // for backward compatibility +export const kCLEAR_EXTRA_TAB_CONTENTS = 'clear-extra-tab-contents'; // for backward compatibility +export const kCLEAR_ALL_EXTRA_TAB_CONTENTS = 'clear-all-extra-tab-contents'; // for backward compatibility +export const kSET_EXTRA_NEW_TAB_BUTTON_CONTENTS = 'set-extra-new-tab-button-contents'; // for backward compatibility +export const kCLEAR_EXTRA_NEW_TAB_BUTTON_CONTENTS = 'clear-extra-new-tab-button-contents'; // for backward compatibility +export const kSET_EXTRA_CONTENTS_PROPERTIES = 'set-extra-contents-properties'; +export const kFOCUS_TO_EXTRA_CONTENTS = 'focus-to-extra-contents'; +export const kGET_DRAG_DATA = 'get-drag-data'; + +const BULK_MESSAGING_TYPES = new Set([ + kNOTIFY_SIDEBAR_SHOW, + kNOTIFY_SIDEBAR_HIDE, + kNOTIFY_TABS_RENDERED, + kNOTIFY_TABS_UNRENDERED, + kNOTIFY_TAB_STICKY_STATE_CHANGED, + kNOTIFY_EXTRA_CONTENTS_FOCUS, + kNOTIFY_EXTRA_CONTENTS_BLUR, + kNOTIFY_TABBAR_OVERFLOW, + kNOTIFY_TABBAR_UNDERFLOW, + kNOTIFY_TAB_MOUSEMOVE, + kNOTIFY_TAB_MOUSEOVER, + kNOTIFY_TAB_MOUSEOUT, + kNOTIFY_TAB_DRAGREADY, + kNOTIFY_TAB_DRAGCANCEL, + kNOTIFY_TAB_DRAGSTART, + kNOTIFY_TAB_DRAGENTER, + kNOTIFY_TAB_DRAGEXIT, + kNOTIFY_TAB_DRAGEND, + kNOTIFY_TREE_ATTACHED, + kNOTIFY_TREE_DETACHED, + kNOTIFY_TREE_COLLAPSED_STATE_CHANGED, + kNOTIFY_NATIVE_TAB_DRAGSTART, + kNOTIFY_NATIVE_TAB_DRAGEND, + kNOTIFY_PERMISSIONS_CHANGED, +]); + +export const kCONTEXT_MENU_OPEN = 'contextMenu-open'; +export const kCONTEXT_MENU_CREATE = 'contextMenu-create'; +export const kCONTEXT_MENU_UPDATE = 'contextMenu-update'; +export const kCONTEXT_MENU_REMOVE = 'contextMenu-remove'; +export const kCONTEXT_MENU_REMOVE_ALL = 'contextMenu-remove-all'; +export const kCONTEXT_MENU_CLICK = 'contextMenu-click'; +export const kCONTEXT_MENU_SHOWN = 'contextMenu-shown'; +export const kCONTEXT_MENU_HIDDEN = 'contextMenu-hidden'; +export const kFAKE_CONTEXT_MENU_OPEN = 'fake-contextMenu-open'; +export const kFAKE_CONTEXT_MENU_CREATE = 'fake-contextMenu-create'; +export const kFAKE_CONTEXT_MENU_UPDATE = 'fake-contextMenu-update'; +export const kFAKE_CONTEXT_MENU_REMOVE = 'fake-contextMenu-remove'; +export const kFAKE_CONTEXT_MENU_REMOVE_ALL = 'fake-contextMenu-remove-all'; +export const kFAKE_CONTEXT_MENU_CLICK = 'fake-contextMenu-click'; +export const kFAKE_CONTEXT_MENU_SHOWN = 'fake-contextMenu-shown'; +export const kFAKE_CONTEXT_MENU_HIDDEN = 'fake-contextMenu-hidden'; +export const kOVERRIDE_CONTEXT = 'override-context'; + +export const kCOMMAND_BROADCAST_API_REGISTERED = 'ws:broadcast-registered'; +export const kCOMMAND_BROADCAST_API_UNREGISTERED = 'ws:broadcast-unregistered'; +export const kCOMMAND_BROADCAST_API_PERMISSION_CHANGED = 'ws:permission-changed'; +export const kCOMMAND_REQUEST_INITIALIZE = 'ws:request-initialize'; +export const kCOMMAND_REQUEST_CONTROL_STATE = 'ws:request-control-state'; +export const kCOMMAND_GET_ADDONS = 'ws:get-addons'; +export const kCOMMAND_SET_API_PERMISSION = 'ws:set-api-permisssion'; +export const kCOMMAND_NOTIFY_PERMISSION_CHANGED = 'ws:notify-api-permisssion-changed'; +export const kCOMMAND_UNREGISTER_ADDON = 'ws:unregister-addon'; + +export const INTERNAL_CALL_PREFIX = 'ws:api:'; +export const INTERNAL_CALL_PREFIX_MATCHER = new RegExp(`^${INTERNAL_CALL_PREFIX}`); + +export const kNEWTAB_CONTEXT_NEWTAB_COMMAND = 'newtab-command'; +export const kNEWTAB_CONTEXT_WITH_OPENER = 'with-opener'; +export const kNEWTAB_CONTEXT_DUPLICATED = 'duplicated'; +export const kNEWTAB_CONTEXT_FROM_PINNED = 'from-pinned'; +export const kNEWTAB_CONTEXT_FROM_EXTERNAL = 'from-external'; +export const kNEWTAB_CONTEXT_WEBSITE_SAME_TO_ACTIVE_TAB = 'website-same-to-active-tab'; +export const kNEWTAB_CONTEXT_FROM_ABOUT_ADDONS = 'from-about-addons'; +export const kNEWTAB_CONTEXT_UNKNOWN = 'unknown'; + +const mAddons = new Map(); +let mScrollLockedBy = {}; +let mGroupingBlockedBy = {}; + +const mPendingMessagesFor = new Map(); +const mMessagesPendedAt = new Map(); + +// you should use this to reduce memory usage around effective favicons +export function clearCache(cache) { + cache.effectiveFavIconUrls = {}; +} + +// This function is complex a little, but we should not make a custom class for this purpose, +// bacause instances of the class will be very short-life and increases RAM usage on +// massive tabs case. +export async function exportTab(sourceTab, { addonId, light, isContextTab, interval, permissions, cache, cacheKey } = {}) { + const normalizedSourceTab = Tab.get(sourceTab); + if (!normalizedSourceTab) + throw new Error(`Fatal error: tried to export not a tab. ${sourceTab}`); + sourceTab = normalizedSourceTab; + + if (!interval) + interval = 0; + if (!cache) + cache = {}; + + if (!cache.tabs) + cache.tabs = {}; + if (!cache.effectiveFavIconUrls) + cache.effectiveFavIconUrls = {}; + + if (!permissions) { + permissions = (!addonId || addonId == browser.runtime.id) ? + kPERMISSIONS_ALL : + new Set(getGrantedPermissionsForAddon(addonId)); + if (addonId && + configs.incognitoAllowedExternalAddons.includes(addonId)) + permissions.add(kPERMISSION_INCOGNITO); + } + if (!cacheKey) + cacheKey = `${sourceTab.id}:${Array.from(permissions).sort().join(',')}`; + + if (!sourceTab || + (sourceTab.incognito && + !permissions.has(kPERMISSION_INCOGNITO))) + return null; + + // The promise is cached here instead of the result, + // to avoid cache miss caused by concurrent call. + if (!(cacheKey in cache.tabs)) { + cache.tabs[cacheKey] = sourceTab.$TST.exportForAPI({ addonId, light, isContextTab, interval, permissions, cache, cacheKey }); + } + return cache.tabs[cacheKey]; +} + +export function getAddon(id) { + return mAddons.get(id); +} + +export function getGrantedPermissionsForAddon(id) { + const addon = getAddon(id); + return addon?.grantedPermissions || new Set(); +} + +function registerAddon(id, addon) { + log('addon is registered: ', id, addon); + + // inherit properties from last effective value + const oldAddon = getAddon(id); + if (oldAddon) { + for (const param of [ + 'name', + 'icons', + 'listeningTypes', + 'allowBulkMessaging', + 'lightTree', + 'style', + 'permissions', + ]) { + if (!(param in addon) && param in oldAddon) { + addon[param] = oldAddon[param]; + } + } + } + + if (!addon.listeningTypes) { + // for backward compatibility, send all message types available on TST 2.4.16 by default. + addon.listeningTypes = [ + kNOTIFY_READY, + kNOTIFY_SHUTDOWN, + kNOTIFY_TAB_CLICKED, + kNOTIFY_TAB_MOUSEDOWN, + kNOTIFY_TAB_MOUSEUP, + kNOTIFY_NEW_TAB_BUTTON_CLICKED, + kNOTIFY_NEW_TAB_BUTTON_MOUSEDOWN, + kNOTIFY_NEW_TAB_BUTTON_MOUSEUP, + kNOTIFY_TABBAR_CLICKED, + kNOTIFY_TABBAR_MOUSEDOWN, + kNOTIFY_TABBAR_MOUSEUP + ]; + } + + let requestedPermissions = addon.permissions || []; + if (!Array.isArray(requestedPermissions)) + requestedPermissions = [requestedPermissions]; + addon.requestedPermissions = new Set(requestedPermissions); + const grantedPermissions = configs.grantedExternalAddonPermissions[id] || []; + addon.grantedPermissions = new Set(grantedPermissions); + + if (Constants.IS_BACKGROUND && + !addon.bypassPermissionCheck && + addon.requestedPermissions.size > 0 && + addon.grantedPermissions.size != addon.requestedPermissions.size) + notifyPermissionRequest(addon, addon.requestedPermissions); + + addon.id = id; + addon.lastRegistered = Date.now(); + mAddons.set(id, addon); + + onRegistered.dispatch(addon); +} + +const mPermissionNotificationForAddon = new Map(); + +async function notifyPermissionRequest(addon, requestedPermissions) { + log('notifyPermissionRequest ', addon, requestedPermissions); + + if (mPermissionNotificationForAddon.has(addon.id)) + return; + + mPermissionNotificationForAddon.set(addon.id, -1); + const id = await browser.notifications.create({ + type: 'basic', + iconUrl: Constants.kNOTIFICATION_DEFAULT_ICON, + title: browser.i18n.getMessage('api_requestedPermissions_title'), + message: browser.i18n.getMessage(`api_requestedPermissions_message${isLinux() ? '_linux' : ''}`, [ + addon.name || addon.title || addon.id, + Array.from(requestedPermissions, permission => { + if (permission == kPERMISSION_INCOGNITO) + return null; + try { + return browser.i18n.getMessage(`api_requestedPermissions_type_${permission}`) || permission; + } + catch(_error) { + return permission; + } + }).filter(permission => !!permission).join('\n') + ]) + }); + mPermissionNotificationForAddon.set(addon.id, id); +} + +function setPermissions(addon, permisssions) { + addon.grantedPermissions = permisssions; + const cachedPermissions = JSON.parse(JSON.stringify(configs.grantedExternalAddonPermissions)); + cachedPermissions[addon.id] = Array.from(addon.grantedPermissions); + configs.grantedExternalAddonPermissions = cachedPermissions; + notifyPermissionChanged(addon); +} + +function notifyPermissionChanged(addon) { + const permissions = Array.from(addon.grantedPermissions); + browser.runtime.sendMessage({ + type: kCOMMAND_BROADCAST_API_PERMISSION_CHANGED, + id: addon.id, + permissions + }); + if (addon.id == browser.runtime.id) + return; + browser.runtime.sendMessage(addon.id, { + type: kNOTIFY_PERMISSIONS_CHANGED, + grantedPermissions: permissions.filter(permission => permission.startsWith('!')), + privateWindowAllowed: configs.incognitoAllowedExternalAddons.includes(addon.id) + }).catch(ApiTabs.createErrorHandler()); +} + +function unregisterAddon(id) { + const addon = getAddon(id); + log('addon is unregistered: ', id, addon); + onUnregistered.dispatch(addon); + mAddons.delete(id); + mPendingMessagesFor.delete(id); + mMessagesPendedAt.delete(id); + delete mScrollLockedBy[id]; + delete mGroupingBlockedBy[id]; +} + +export function getAddons() { + return mAddons.entries(); +} + +const mConnections = new Map(); + +function onCommonCommand(message, sender) { + if (!message || + typeof message.type != 'string') + return; + + const addon = getAddon(sender.id); + + switch (message.type) { + case kSCROLL_LOCK: + mScrollLockedBy[sender.id] = true; + if (!addon) + registerAddon(sender.id, sender); + return Promise.resolve(true); + + case kSCROLL_UNLOCK: + delete mScrollLockedBy[sender.id]; + return Promise.resolve(true); + + case kBLOCK_GROUPING: + mGroupingBlockedBy[sender.id] = true; + if (!addon) + registerAddon(sender.id, sender); + return Promise.resolve(true); + + case kUNBLOCK_GROUPING: + delete mGroupingBlockedBy[sender.id]; + return Promise.resolve(true); + + case kSET_EXTRA_TAB_CONTENTS: + if (!addon) + registerAddon(sender.id, sender); + break; + + case kSET_EXTRA_NEW_TAB_BUTTON_CONTENTS: + if (!addon) + registerAddon(sender.id, sender); + break; + } +} + + +// ======================================================================= +// for backend +// ======================================================================= + +let mInitialized = false; +let mPromisedInitialized = null; + +if (Constants.IS_BACKGROUND) { + browser.runtime.onMessageExternal.addListener(onBackendCommand); + browser.runtime.onConnectExternal.addListener(port => { + if (!mInitialized || + !configs.APIEnabled) + return; + const sender = port.sender; + mConnections.set(sender.id, port); + port.onMessage.addListener(message => { + const messages = message.messages || [message]; + for (const oneMessage of messages) { + onMessageExternal.dispatch(oneMessage, sender); + SidebarConnection.sendMessage({ + type: 'external', + oneMessage, + sender + }); + } + }); + port.onDisconnect.addListener(_message => { + mConnections.delete(sender.id); + onBackendCommand({ + type: kUNREGISTER_SELF, + sender + }).catch(ApiTabs.createErrorSuppressor()); + }); + }); +} + +export async function initAsBackend() { + // We must listen API messages from other addons here beacause: + // * Before notification messages are sent to other addons. + // * After configs are loaded and TST's background page is almost completely initialized. + // (to prevent troubles like breakage of `configs.cachedExternalAddons`, see also: + // https://github.com/piroor/treestyletab/issues/2300#issuecomment-498947370 ) + mInitialized = true; + + let resolver; + mPromisedInitialized = new Promise((resolve, _reject) => { + resolver = resolve; + }); + + const manifest = browser.runtime.getManifest(); + registerAddon(browser.runtime.id, { + id: browser.runtime.id, + internalId: browser.runtime.getURL('').replace(/^moz-extension:\/\/([^\/]+)\/.*$/, '$1'), + icons: manifest.icons, + listeningTypes: [ + kNOTIFY_EXTRA_CONTENTS_CLICKED, + kNOTIFY_EXTRA_CONTENTS_DBLCLICKED, + kNOTIFY_EXTRA_CONTENTS_MOUSEDOWN, + kNOTIFY_EXTRA_CONTENTS_MOUSEUP, + kNOTIFY_EXTRA_CONTENTS_KEYDOWN, + kNOTIFY_EXTRA_CONTENTS_KEYUP, + kNOTIFY_EXTRA_CONTENTS_INPUT, + kNOTIFY_EXTRA_CONTENTS_CHANGE, + kNOTIFY_EXTRA_CONTENTS_COMPOSITIONSTART, + kNOTIFY_EXTRA_CONTENTS_COMPOSITIONUPDATE, + kNOTIFY_EXTRA_CONTENTS_COMPOSITIONEND, + kNOTIFY_EXTRA_CONTENTS_FOCUS, + kNOTIFY_EXTRA_CONTENTS_BLUR, + ], + bypassPermissionCheck: true, + allowBulkMessaging: true, + lightTree: true, + }); + + const respondedAddons = []; + const notifiedAddons = {}; + const notifyAddons = configs.knownExternalAddons.concat(configs.cachedExternalAddons); + log('initAsBackend: notifyAddons = ', notifyAddons); + await Promise.all(notifyAddons.map(async id => { + if (id in notifiedAddons) + return; + notifiedAddons[id] = true; + try { + id = await new Promise((resolve, reject) => { + let responded = false; + browser.runtime.sendMessage(id, { + type: kNOTIFY_READY + }).then(() => { + responded = true; + resolve(id); + }).catch(ApiTabs.createErrorHandler(reject)); + setTimeout(() => { + if (!responded) + reject(new Error(`TSTAPI.initAsBackend: addon ${id} does not respond.`)); + }, 3000); + }); + if (id) + respondedAddons.push(id); + } + catch(e) { + console.log(`TSTAPI.initAsBackend: failed to send "ready" message to "${id}":`, e); + } + })); + log('initAsBackend: respondedAddons = ', respondedAddons); + configs.cachedExternalAddons = respondedAddons; + + onInitialized.dispatch(); + resolver(); +} + +if (Constants.IS_BACKGROUND) { + browser.notifications.onClicked.addListener(notificationId => { + if (!mInitialized) + return; + + for (const [addonId, id] of mPermissionNotificationForAddon.entries()) { + if (id != notificationId) + continue; + mPermissionNotificationForAddon.delete(addonId); + browser.tabs.create({ + url: `moz-extension://${location.host}/options/options.html#externalAddonPermissionsGroup` + }); + break; + } + }); + + browser.notifications.onClosed.addListener((notificationId, _byUser) => { + if (!mInitialized) + return; + + for (const [addonId, id] of mPermissionNotificationForAddon.entries()) { + if (id != notificationId) + continue; + mPermissionNotificationForAddon.delete(addonId); + break; + } + }); + + SidebarConnection.onConnected.addListener((windowId, openCount) => { + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_CONNECTION_READY, + windowId, + openCount, + }); + }); + + SidebarConnection.onDisconnected.addListener((windowId, openCount) => { + broadcastMessage({ + type: kNOTIFY_SIDEBAR_HIDE, + window: windowId, + windowId, + openCount + }); + }); + + /* + // This mechanism doesn't work actually. + // See also: https://github.com/piroor/treestyletab/issues/2128#issuecomment-454650407 + + const mConnectionsForAddons = new Map(); + + browser.runtime.onConnectExternal.addListener(port => { + if (!mInitialized) + return; + + const sender = port.sender; + log('Connected: ', sender.id); + + const connections = mConnectionsForAddons.get(sender.id) || new Set(); + connections.add(port); + + const addon = getAddon(sender.id); + if (!addon) { // treat as register-self + const message = { + id: sender.id, + internalId: sender.url.replace(/^moz-extension:\/\/([^\/]+)\/.*$/, '$1'), + newlyInstalled: !configs.cachedExternalAddons.includes(sender.id) + }; + registerAddon(sender.id, message); + browser.runtime.sendMessage({ + type: kCOMMAND_BROADCAST_API_REGISTERED, + sender, + message + }).catch(ApiTabs.createErrorSuppressor()); + if (message.newlyInstalled) + configs.cachedExternalAddons = configs.cachedExternalAddons.concat([sender.id]); + } + + const onMessage = message => { + onBackendCommand(message, sender); + }; + port.onMessage.addListener(onMessage); + + const onDisconnected = _message => { + log('Disconnected: ', sender.id); + port.onMessage.removeListener(onMessage); + port.onDisconnect.removeListener(onDisconnected); + + connections.delete(port); + if (connections.size > 0) + return; + + setTimeout(() => { + // if it is not re-registered while 10sec, it may be uninstalled. + if (getAddon(sender.id)) + return; + configs.cachedExternalAddons = configs.cachedExternalAddons.filter(id => id != sender.id); + }, 10 * 1000); + browser.runtime.sendMessage({ + type: kCOMMAND_BROADCAST_API_UNREGISTERED, + sender + }).catch(ApiTabs.createErrorSuppressor()); + unregisterAddon(sender.id); + mConnectionsForAddons.delete(sender.id); + } + port.onDisconnect.addListener(onDisconnected); + }); + */ + + browser.runtime.onMessage.addListener((message, _sender) => { + if (!mInitialized || + !message || + typeof message.type != 'string') + return; + + switch (message.type) { + case kCOMMAND_REQUEST_INITIALIZE: + return Promise.resolve({ + addons: exportAddons(), + scrollLocked: mScrollLockedBy, + groupingLocked: mGroupingBlockedBy + }); + + case kCOMMAND_REQUEST_CONTROL_STATE: + return Promise.resolve({ + scrollLocked: mScrollLockedBy, + groupingLocked: mGroupingBlockedBy + }); + + case kCOMMAND_GET_ADDONS: + return mPromisedInitialized.then(() => { + const addons = []; + for (const [id, addon] of mAddons.entries()) { + addons.push({ + id, + label: addon.name || addon.title || addon.id, + permissions: Array.from(addon.requestedPermissions), + permissionsGranted: Array.from(addon.requestedPermissions).join(',') == Array.from(addon.grantedPermissions).join(',') + }); + } + return addons; + }); + + case kCOMMAND_SET_API_PERMISSION: + setPermissions(getAddon(message.id), new Set(message.permissions)); + break; + + case kCOMMAND_NOTIFY_PERMISSION_CHANGED: + notifyPermissionChanged(getAddon(message.id)); + break; + + case kCOMMAND_UNREGISTER_ADDON: + unregisterAddon(message.id); + break; + } + }); +} + +const mPromisedOnBeforeUnload = new Promise((resolve, _reject) => { + // If this promise doesn't do anything then there seems to be a timeout so it only works if TST is disabled within about 10 seconds after this promise is used as a response to a message. After that it will not throw an error for the waiting extension. + // If we use the following then the returned promise will be rejected when TST is disabled even for longer times: + window.addEventListener('beforeunload', () => resolve()); +}); + +const mWaitingShutdownMessages = new Map(); + +function onBackendCommand(message, sender) { + if (message?.messages) + return Promise.all( + message.messages.map(oneMessage => onBackendCommand(oneMessage, sender)) + ); + + if (!mInitialized || + !message || + typeof message != 'object' || + typeof message.type != 'string') + return; + + const results = onMessageExternal.dispatch(message, sender); + const firstPromise = results.find(result => result instanceof Promise); + if (firstPromise) + return firstPromise; + + switch (message.type) { + case kPING: + return Promise.resolve(true); + + case kREGISTER_SELF: + return (async () => { + message.internalId = sender.url.replace(/^moz-extension:\/\/([^\/]+)\/.*$/, '$1'); + message.id = sender.id; + message.subPanel = message.subPanel || message.subpanel || null; + if (message.subPanel) { + const url = typeof message.subPanel.url === 'string' && new URL(message.subPanel.url, new URL('/', sender.url)); + if (!url || url.hostname !== message.internalId) { + console.error(`"subPanel.url" must refer to a page packed in the registering extension.`); + message.subPanel.url = 'about:blank?error=invalid-origin' + } else + message.subPanel.url = url.href; + } + message.newlyInstalled = !configs.cachedExternalAddons.includes(sender.id); + registerAddon(sender.id, message); + browser.runtime.sendMessage({ + type: kCOMMAND_BROADCAST_API_REGISTERED, + sender: sender, + message: message + }).catch(ApiTabs.createErrorSuppressor()); + if (message.newlyInstalled) + configs.cachedExternalAddons = configs.cachedExternalAddons.concat([sender.id]); + if (message.listeningTypes && + message.listeningTypes.includes(kWAIT_FOR_SHUTDOWN) && + !mWaitingShutdownMessages.has(sender.id)) { + const onShutdown = () => { + const storedShutdown = mWaitingShutdownMessages.get(sender.id); + // eslint-disable-next-line no-use-before-define + if (storedShutdown && storedShutdown !== promisedShutdown) + return; // it is obsolete + + const addon = getAddon(sender.id); + const lastRegistered = addon?.lastRegistered; + setTimeout(() => { + // if it is re-registered immediately, it was updated or reloaded. + const addon = getAddon(sender.id); + if (addon && + addon.lastRegistered != lastRegistered) + return; + // otherwise it is uninstalled. + browser.runtime.sendMessage({ + type: kCOMMAND_BROADCAST_API_UNREGISTERED, + sender + }).catch(ApiTabs.createErrorSuppressor()); + unregisterAddon(sender.id); + configs.cachedExternalAddons = configs.cachedExternalAddons.filter(id => id != sender.id); + }, 350); + }; + const promisedShutdown = (async () => { + try { + const shouldUninit = await browser.runtime.sendMessage(sender.id, { + type: kWAIT_FOR_SHUTDOWN + }); + if (!shouldUninit) + return; + } + catch (_error) { + // Extension was disabled. + } + finally { + mWaitingShutdownMessages.delete(sender.id); + } + onShutdown(); + })(); + mWaitingShutdownMessages.set(sender.id, promisedShutdown); + promisedShutdown.catch(onShutdown); + } + return { + grantedPermissions: Array.from(getGrantedPermissionsForAddon(sender.id)).filter(permission => permission.startsWith('!')), + privateWindowAllowed: configs.incognitoAllowedExternalAddons.includes(sender.id) + }; + })(); + + case kUNREGISTER_SELF: + return (async () => { + browser.runtime.sendMessage({ + type: kCOMMAND_BROADCAST_API_UNREGISTERED, + sender + }).catch(ApiTabs.createErrorSuppressor()); + unregisterAddon(sender.id); + configs.cachedExternalAddons = configs.cachedExternalAddons.filter(id => id != sender.id); + return true; + })(); + + case kWAIT_FOR_SHUTDOWN: + return mPromisedOnBeforeUnload; + + default: + return onCommonCommand(message, sender); + } +} + +function exportAddons() { + const exported = {}; + for (const [id, addon] of getAddons()) { + exported[id] = addon; + } + return exported; +} + +export function isGroupingBlocked() { + return Object.keys(mGroupingBlockedBy).length > 0; +} + + +// ======================================================================= +// for frontend +// ======================================================================= + +export async function initAsFrontend() { + let resolver; + mPromisedInitialized = new Promise((resolve, _reject) => { + resolver = resolve; + }); + log('initAsFrontend: start'); + let response; + while (true) { + response = await browser.runtime.sendMessage({ type: kCOMMAND_REQUEST_INITIALIZE }); + if (response) + break; + await wait(10); + } + browser.runtime.onMessageExternal.addListener(onFrontendCommand); + log('initAsFrontend: response = ', response); + importAddons(response.addons); + for (const [, addon] of getAddons()) { + onRegistered.dispatch(addon); + } + mScrollLockedBy = response.scrollLocked; + mGroupingBlockedBy = response.groupingLocked; + + onInitialized.dispatch(); + log('initAsFrontend: finish'); + resolver(); + mPromisedInitialized = null; +} + +function onFrontendCommand(message, sender) { + //console.log('onFrontendCommand ', message, sender); + if (!configs.APIEnabled) + return; + + if (message?.messages) + return Promise.all( + message.messages.map(oneMessage => onFrontendCommand(oneMessage, sender)) + ); + + if (message && + typeof message == 'object' && + typeof message.type == 'string') { + const results = onMessageExternal.dispatch(message, sender); + log('onMessageExternal: ', message, ' => ', results, 'sender: ', sender); + const firstPromise = results.find(result => result instanceof Promise); + if (firstPromise) + return firstPromise; + } + if (configs.incognitoAllowedExternalAddons.includes(sender.id) || + !document.documentElement.classList.contains('incognito')) + return onCommonCommand(message, sender); +} + +if (Constants.IS_SIDEBAR) { + browser.runtime.onMessage.addListener((message, _sender) => { + if (!message || + typeof message != 'object' || + typeof message.type != 'string') + return; + + switch (message.type) { + case kCOMMAND_BROADCAST_API_REGISTERED: + registerAddon(message.sender.id, message.message); + break; + + case kCOMMAND_BROADCAST_API_UNREGISTERED: + unregisterAddon(message.sender.id); + break; + + case kCOMMAND_BROADCAST_API_PERMISSION_CHANGED: { + const addon = getAddon(message.id); + addon.grantedPermissions = new Set(message.permissions); + }; break; + } + }); +} + +function importAddons(addons) { + if (!addons) + console.log(new Error('null import')); + for (const id of Object.keys(mAddons)) { + unregisterAddon(id); + } + for (const [id, addon] of Object.entries(addons)) { + registerAddon(id, addon); + } +} + +export function isScrollLocked() { + return Object.keys(mScrollLockedBy).length > 0; +} + +export async function notifyScrolled(params = {}) { + const lockers = Object.keys(mScrollLockedBy); + const tab = params.tab; + const windowId = TabsStore.getCurrentWindowId(); + const tabs = Tab.getTabs(windowId); + const cache = {}; + const results = await broadcastMessage({ + type: kNOTIFY_SCROLLED, + tab: tab && tabs.find(another => another.id == tab.id), + tabs, + overflow: params.overflow, + window: windowId, + windowId, + + deltaX: params.event.deltaX, + deltaY: params.event.deltaY, + deltaZ: params.event.deltaZ, + deltaMode: params.event.deltaMode, + scrollTop: params.scrollContainer.scrollTop, + scrollTopMax: params.scrollContainer.scrollTopMax, + + altKey: params.event.altKey, + ctrlKey: params.event.ctrlKey, + metaKey: params.event.metaKey, + shiftKey: params.event.shiftKey, + + clientX: params.event.clientX, + clientY: params.event.clientY, + }, { + targets: lockers, + tabProperties: ['tab', 'tabs'], + cache, + }); + for (const result of results) { + if (!result || result.error || result.result === undefined) + delete mScrollLockedBy[result.id]; + } + clearCache(cache); +} + + +// ======================================================================= +// Common utilities to send notification messages to other addons +// ======================================================================= + +export async function tryOperationAllowed(type, message = {}, { targets, except, tabProperties, cache } = {}) { + if (mPromisedInitialized) + await mPromisedInitialized; + + if (!hasListenerForMessageType(type, { targets, except })) { + //log(`=> ${type}: no listener, always allowed`); + return true; + } + cache = cache || {}; + const results = await broadcastMessage({ ...message, type }, { + targets, + except, + tabProperties, + cache, + }).catch(error => { + if (configs.debug) + console.error(error); + return []; + }); + if (!results) { + log(`=> ${type}: allowed because no one responded`); + return true; + } + if (results.flat().some(result => result?.result)) { + log(`=> ${type}: canceled by some helper addon`); + return false; + } + log(`=> ${type}: allowed by all helper addons`); + return true; +} + +export function hasListenerForMessageType(type, { targets, except } = {}) { + return getListenersForMessageType(type, { targets, except }).length > 0; +} + +export function getListenersForMessageType(type, { targets, except } = {}) { + targets = targets instanceof Set ? targets : new Set(Array.isArray(targets) ? targets : targets ? [targets] : []); + except = except instanceof Set ? except : new Set(Array.isArray(except) ? except : except ? [except] : []); + + const finalTargets = new Set(); + for (const [id, addon] of getAddons()) { + if (addon.listeningTypes.includes(type) && + (targets.size == 0 || targets.has(id)) && + !except.has(id)) + finalTargets.add(id); + } + //log('getListenersForMessageType ', { type, targets, except, finalTargets, all: mAddons }); + return Array.from(finalTargets, getAddon); +} + +export async function sendMessage(addonId, message, { tabProperties, cache, isContextTab } = {}) { + if (mPromisedInitialized) + await mPromisedInitialized; + + cache = cache || {}; + + const incognitoParams = { windowId: message.windowId || message.window }; + for (const key of tabProperties) { + if (!message[key]) + continue; + if (Array.isArray(message[key])) + incognitoParams.tab = message[key][0].tab; + else + incognitoParams.tab = message[key].tab; + break; + } + if (!isSafeAtIncognito(addonId, incognitoParams)) + throw new Error(`Message from incognito source is not allowed for ${addonId}`); + + const safeMessage = await sanitizeMessage(message, { addonId, tabProperties, cache, isContextTab }); + const result = directSendMessage(addonId, safeMessage); + if (result.error) + throw result.error; + return result.result; +} + +export async function broadcastMessage(message, { targets, except, tabProperties, cache, isContextTab } = {}) { + if (!configs.APIEnabled) + return []; + + if (mPromisedInitialized) + await mPromisedInitialized; + + const listenerAddons = getListenersForMessageType(message.type, { targets, except }); + tabProperties = tabProperties || []; + cache = cache || {}; + log(`broadcastMessage: sending message for ${message.type}: `, { + message, + listenerAddons, + tabProperties + }); + + const promisedResults = spawnMessages(new Set(listenerAddons.map(addon => addon.id)), { + message, + tabProperties, + cache, + isContextTab, + }); + return Promise.all(promisedResults).then(results => { + log(`broadcastMessage: got responses for ${message.type}: `, results); + return results; + }).catch(ApiTabs.createErrorHandler()); +} + +function* spawnMessages(targets, { message, tabProperties, cache, isContextTab }) { + tabProperties = tabProperties || []; + cache = cache || {}; + + const incognitoParams = { windowId: message.windowId || message.window }; + for (const key of tabProperties) { + if (!message[key]) + continue; + if (Array.isArray(message[key])) + incognitoParams.tab = message[key][0].tab; + else + incognitoParams.tab = message[key].tab; + break; + } + + const send = async (id) => { + if (!isSafeAtIncognito(id, incognitoParams)) + return { + id, + result: undefined + }; + + const allowedMessage = await sanitizeMessage(message, { addonId: id, tabProperties, cache, isContextTab }); + const addon = getAddon(id) || {}; + if (BULK_MESSAGING_TYPES.has(message.type) && + addon.allowBulkMessaging) { + const startAt = `${Date.now()}-${parseInt(Math.random() * 65000)}`; + mMessagesPendedAt.set(id, startAt); + const messages = mPendingMessagesFor.get(id) || []; + messages.push(allowedMessage); + mPendingMessagesFor.set(id, messages); + (Constants.IS_BACKGROUND ? + setTimeout : // because window.requestAnimationFrame is decelerate for an invisible document. + window.requestAnimationFrame)(() => { + if (mMessagesPendedAt.get(id) != startAt) + return; + const messages = mPendingMessagesFor.get(id); + mPendingMessagesFor.delete(id); + if (!messages || messages.length == 0) + return; + directSendMessage(id, messages.length == 1 ? messages[0] : { messages }); + }, 0); + return { + id, + result: null, + }; + } + + return directSendMessage(id, allowedMessage); + }; + + for (const id of targets) { + yield send(id); + } +} +async function directSendMessage(id, message) { + try { + const result = await (id == browser.runtime.id ? + browser.runtime.sendMessage( + Array.isArray(message) ? + message.map(message => ({ ...message, type: `${INTERNAL_CALL_PREFIX}${message.type}` })) : + ({ ...message, type: `${INTERNAL_CALL_PREFIX}${message.type}` }) + ) : + browser.runtime.sendMessage(id, message)); + return { + id, + result, + }; + } + catch(error) { + console.log(`Error on sending message to ${id}`, message, error); + if (error && + error.message == 'Could not establish connection. Receiving end does not exist.') { + browser.runtime.sendMessage(id, { type: kNOTIFY_READY }).catch(_error => { + console.log(`Unregistering missing helper addon ${id}...`); + unregisterAddon(id); + if (Constants.IS_SIDEBAR) + browser.runtime.sendMessage({ type: kCOMMAND_UNREGISTER_ADDON, id }); + }); + } + return { + id, + error, + }; + } +} + +export function isSafeAtIncognito(addonId, { tab, windowId }) { + if (addonId == browser.runtime.id) + return true; + const win = windowId && TabsStore.windows.get(windowId); + const hasIncognitoInfo = win?.incognito || tab?.incognito; + return !hasIncognitoInfo || configs.incognitoAllowedExternalAddons.includes(addonId); +} + +async function sanitizeMessage(message, { addonId, tabProperties, cache, isContextTab }) { + const addon = getAddon(addonId); + if (!message || + !tabProperties || + tabProperties.length == 0 || + addon.bypassPermissionCheck) + return message; + + cache = cache || {}; + + const sanitizedProperties = {}; + const tasks = []; + if (tabProperties) { + for (const name of tabProperties) { + const treeItem = message[name]; + if (!treeItem) + continue; + if (Array.isArray(treeItem)) + tasks.push((async treeItems => { + const tabs = await Promise.all(treeItems.map(treeItem => exportTab(treeItem, { + addonId: addon.id, + light: !!addon.lightTree, + cache, + isContextTab, + }))); + sanitizedProperties[name] = tabs.filter(tab => !!tab); + })(treeItem)); + else + tasks.push((async () => { + sanitizedProperties[name] = await exportTab(treeItem, { + addonId: addon.id, + light: !!addon.lightTree, + cache, + isContextTab, + }); + })()); + } + } + await Promise.all(tasks); + return { ...message, ...sanitizedProperties }; +} + + +// ======================================================================= +// Common utilities for request-response type API call +// ======================================================================= + +export async function getTargetTabs(message, sender) { + const tabQuery = message.tabs || message.tabIds || message.tab || message.tabId; + const windowId = message.window || message.windowId; + + if (Array.isArray(tabQuery)) + await Promise.all(tabQuery.map(oneTabQuery => { + if (typeof oneTabQuery == 'number') + return Tab.waitUntilTracked(oneTabQuery) + return true; + })); + else if (typeof tabQuery == 'number') + await Tab.waitUntilTracked(tabQuery); + + if (windowId) + await Tab.waitUntilTrackedAll(windowId); + + const queryOptions = {}; + if (Array.isArray(queryOptions.states)) { + queryOptions.states = queryOptions.states || []; + queryOptions.states.push(...queryOptions.states.map(state => [state, true])); + } + if (Array.isArray(queryOptions.statesNot)) { + queryOptions.states = queryOptions.statesNot || []; + queryOptions.states.push(...queryOptions.statesNot.map(state => [state, false])); + } + + if (Array.isArray(tabQuery)) + return getTabsByQueries(tabQuery, { windowId, queryOptions, sender }); + + if (windowId) { + if (tabQuery == '*') + return Tab.getAllTabs(windowId, { ...queryOptions, iterator: true }); + else if (!tabQuery) + return Tab.getRootTabs(windowId, { ...queryOptions, iterator: true }); + } + if (tabQuery == '*') { + const win = await browser.windows.getLastFocused({ + windowTypes: ['normal'] + }).catch(ApiTabs.createErrorHandler()); + return Tab.getAllTabs(win.id, { ...queryOptions, iterator: true }); + } + if (tabQuery) { + let tabs = await getTabsByQueries([tabQuery], { windowId, queryOptions, sender }); + if (queryOptions.states) + tabs = tabs.filter(tab => { + const unified = new Set([...tab.$TST.states, ...queryOptions.states]); + return unified.size == tab.$TST.states.size; + }); + if (queryOptions.statesNot) + tabs = tabs.filter(tab => { + const unified = new Set([...tab.$TST.states, ...queryOptions.statesNot]); + return unified.size > tab.$TST.states.size; + }); + return tabs; + } + return []; +} + +export async function getTargetRenderedTabs(message, sender) { + // Don't touch to this "tabs" until they are finally returned. + // Populating it to an array while operations will finishes + // the iterator and returned tabs will become just blank. + const tabs = await getTargetTabs(message, sender); + if (!tabs) + return tabs; + + const windowId = message.window || + message.windowId || + await browser.windows.getLastFocused({ + windowTypes: ['normal'] + }).catch(ApiTabs.createErrorHandler()).then(win => win?.id); + const renderedTabIds = await browser.runtime.sendMessage({ + type: Constants.kCOMMAND_GET_RENDERED_TAB_IDS, + windowId, + }); + const renderedTabIdsSet = new Set(renderedTabIds); + return Array.from(tabs).filter(tab => renderedTabIdsSet.has(tab.id)); +} + +async function getTabsByQueries(queries, { windowId, queryOptions, sender }) { + const win = !windowId && await browser.windows.getLastFocused({ + populate: true + }).catch(ApiTabs.createErrorHandler()); + const activeWindow = TabsStore.windows.get(windowId || win.id) || win; + const tabs = await Promise.all(queries.map(query => getTabsByQuery(query, { activeWindow, queryOptions, sender }).catch(error => { + console.error(error); + return null; + }))); + log('getTabsByQueries: ', queries, ' => ', tabs, 'sender: ', sender, windowId); + + return tabs.flat().filter(tab => !!tab); +} + +async function getTabsByQuery(query, { activeWindow, queryOptions, sender }) { + log('getTabsByQuery: ', { query, activeWindow, queryOptions, sender }); + if (query && typeof query == 'object' && typeof query.id == 'number') // tabs.Tab + query = query.id; + let id = query; + query = String(query).toLowerCase(); + let baseTab = Tab.getActiveTab(activeWindow.id); + + // this sometimes happen when the active tab was detached from the window + if (!baseTab) + return null; + + const nonActiveTabMatched = query.match(/^([^-]+)-of-(.+)$/i); + if (nonActiveTabMatched) { + query = nonActiveTabMatched[1]; + id = nonActiveTabMatched[2]; + if (/^\d+$/.test(id)) + id = parseInt(id); + baseTab = Tab.get(id) || Tab.getByUniqueId(id); + if (!baseTab) + return null; + } + switch (query) { + case 'active': + case 'current': + return baseTab; + + case 'parent': + return baseTab.$TST.parent; + + case 'root': + return baseTab.$TST.rootTab; + + case 'next': + return baseTab.$TST.nextTab; + case 'nextcyclic': + return baseTab.$TST.nextTab || Tab.getFirstTab(baseTab.windowId, queryOptions || {}); + + case 'previous': + case 'prev': + return baseTab.$TST.previousTab; + case 'previouscyclic': + case 'prevcyclic': + return baseTab.$TST.previousTab || Tab.getLastTab(baseTab.windowId, queryOptions || {}); + + case 'nextsibling': + return baseTab.$TST.nextSiblingTab; + case 'nextsiblingcyclic': { + const nextSibling = baseTab.$TST.nextSiblingTab; + if (nextSibling) + return nextSibling; + const parent = baseTab.$TST.parent; + if (parent) + return parent.$TST.firstChild; + return Tab.getFirstTab(baseTab.windowId, queryOptions || {}); + } + + case 'previoussibling': + case 'prevsibling': + return baseTab.$TST.previousSiblingTab; + case 'previoussiblingcyclic': + case 'prevsiblingcyclic': { + const previousSiblingTab = baseTab.$TST.previousSiblingTab; + if (previousSiblingTab) + return previousSiblingTab; + const parent = baseTab.$TST.parent; + if (parent) + return parent.$TST.lastChild; + return Tab.getLastRootTab(baseTab.windowId, queryOptions || {}); + } + + case 'nextvisible': + return baseTab.$TST.nearestVisibleFollowingTab; + case 'nextvisiblecyclic': + return baseTab.$TST.nearestVisibleFollowingTab || Tab.getFirstVisibleTab(baseTab.windowId, queryOptions || {}); + + case 'previousvisible': + case 'prevvisible': + return baseTab.$TST.nearestVisiblePrecedingTab; + case 'previousvisiblecyclic': + case 'prevvisiblecyclic': + return baseTab.$TST.nearestVisiblePrecedingTab || Tab.getLastVisibleTab(baseTab.windowId, queryOptions || {}); + + case 'lastdescendant': + return baseTab.$TST.lastDescendant; + + case 'sendertab': + return Tab.get(sender?.tab?.id) || null; + + + case 'highlighted': + case 'multiselected': + return Tab.getHighlightedTabs(baseTab.windowId, queryOptions || {}); + + case 'allvisibles': + return Tab.getVisibleTabs(baseTab.windowId, queryOptions || {}); + + case 'normalvisibles': + return Tab.getVisibleTabs(baseTab.windowId, { ...(queryOptions || {}), normal: true }); + + + default: + return Tab.get(id) || Tab.getByUniqueId(id); + } +} + +export function formatResult(results, originalMessage) { + if (Array.isArray(originalMessage.tabs) || + originalMessage.tab == '*' || + originalMessage.tabs == '*') + return results; + if (originalMessage.tab) + return results[0]; + return results; +} + +const TABS_ARRAY_QUERY_MATCHER = /^(\*|allvisibles|normalvisibles)$/i; + +export async function formatTabResult(exportedTabs, originalMessage) { + exportedTabs = await Promise.all(exportedTabs); + if (Array.isArray(originalMessage.tabs) || + TABS_ARRAY_QUERY_MATCHER.test(originalMessage.tab) || + TABS_ARRAY_QUERY_MATCHER.test(originalMessage.tabs)) + return exportedTabs.filter(tab => !!tab); + return exportedTabs.length == 0 ? + null : + exportedTabs[0]; +} diff --git a/waterfox/browser/components/sidebar/common/unique-id.js b/waterfox/browser/components/sidebar/common/unique-id.js new file mode 100644 index 000000000000..89969cbe992d --- /dev/null +++ b/waterfox/browser/components/sidebar/common/unique-id.js @@ -0,0 +1,181 @@ +/* +# 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'; + +import { + log as internalLogger, + configs, + wait, +} from './common.js'; +import * as ApiTabs from './api-tabs.js'; +import * as Constants from './constants.js'; +import * as TabsStore from './tabs-store.js'; + +// eslint-disable-next-line no-unused-vars +function log(...args) { + internalLogger('common/unique-id', ...args); +} + +//=================================================================== +// Unique Tab ID +//=================================================================== + +// for generated IDs +const kID_ADJECTIVES = ` +Agile +Breezy +Cheerful +Dapper +Edgy +Feisty +Gutsy +Hoary +Intrepid +Jaunty +Karmic +Lucid +Marveric +Natty +Oneiric +Precise +Quantal +Raring +Saucy +Trusty +Utopic +Vivid +Warty +Xenial +Yakkety +Zesty +`.toLowerCase().trim().split(/\s+/); + +const kID_NOUNS = ` +Alpaca +Badger +Cat +Drake +Eft +Fawn +Gibbon +Heron +Ibis +Jackalope +Koala +Lynx +Meerkat +Narwhal +Ocelot +Pangolin +Quetzal +Ringtail +Salamander +Tahr +Unicorn +Vervet +Werwolf +Xerus +Yak +Zapus +`.toLowerCase().trim().split(/\s+/); + +let mReadyToDetectDuplicatedTab = false; + +export function readyToDetectDuplicatedTab() { + mReadyToDetectDuplicatedTab = true; +} + +export async function request(tabOrId, options = {}) { + if (typeof options != 'object') + options = {}; + + let tab = tabOrId; + if (typeof tabOrId == 'number') + tab = TabsStore.tabs.get(tabOrId); + + if (TabsStore.getCurrentWindowId()) { + return browser.runtime.sendMessage({ + type: Constants.kCOMMAND_REQUEST_UNIQUE_ID, + tabId: tab.id + }).catch(ApiTabs.createErrorHandler()); + } + + let originalId = null; + let originalTabId = null; + let duplicated = false; + if (!options.forceNew) { + // https://github.com/piroor/treestyletab/issues/2845 + // This delay may break initial restoration of tabs, so we should + // ignore it until all restoration processes are finished. + if (mReadyToDetectDuplicatedTab && + configs.delayForDuplicatedTabDetection > 0) + await wait(configs.delayForDuplicatedTabDetection); + + let oldId = await browser.sessions.getTabValue(tab.id, Constants.kPERSISTENT_ID).catch(ApiTabs.createErrorHandler()); + if (oldId && !oldId.tabId) // ignore broken information! + oldId = null; + + if (oldId) { + // If the tab detected from stored tabId is different, it is duplicated tab. + try { + const tabWithOldId = TabsStore.tabs.get(oldId.tabId); + if (!tabWithOldId) + throw new Error(`Invalid tab ID: ${oldId.tabId}`); + originalId = (tabWithOldId.$TST.uniqueId || await tabWithOldId.$TST.promisedUniqueId).id; + duplicated = tab && tabWithOldId.id != tab.id && originalId == oldId.id; + if (duplicated) + originalTabId = oldId.tabId; + else + throw new Error(`Invalid tab ID: ${oldId.tabId}`); + } + catch(e) { + ApiTabs.handleMissingTabError(e); + // It fails if the tab doesn't exist. + // There is no live tab for the tabId, thus + // this seems to be a tab restored from session. + // We need to update the related tab id. + browser.sessions.setTabValue(tab.id, Constants.kPERSISTENT_ID, { + id: oldId.id, + tabId: tab.id + }).catch(ApiTabs.createErrorSuppressor()); + return { + id: oldId.id, + originalId: null, + originalTabId: oldId.tabId, + restored: true + }; + } + } + } + + const id = `tab-${generate()}`; + // tabId is for detecttion of duplicated tabs + await browser.sessions.setTabValue(tab.id, Constants.kPERSISTENT_ID, { id, tabId: tab.id }).catch(ApiTabs.createErrorSuppressor()); + return { id, originalId, originalTabId, duplicated }; +} + +function generate() { + const adjective = kID_ADJECTIVES[Math.floor(Math.random() * kID_ADJECTIVES.length)]; + const noun = kID_NOUNS[Math.floor(Math.random() * kID_NOUNS.length)]; + const randomValue = Math.floor(Math.random() * 1000); + return `${adjective}-${noun}-${Date.now()}-${randomValue}`; +} + +export async function getFromTabs(tabs) { + return Promise.all(tabs.map(tab => + browser.sessions.getTabValue(tab.id, Constants.kPERSISTENT_ID).catch(ApiTabs.createErrorHandler()) + )); +} + +export async function ensureWindowId(windowId) { + const storedUniqueId = await browser.sessions.getWindowValue(windowId, 'uniqueId').catch(_ => null); + if (storedUniqueId) + return storedUniqueId; + + const uniqueId = `window-${generate()}`; + await browser.sessions.setWindowValue(windowId, 'uniqueId', uniqueId).catch(_ => null); + return uniqueId; +} diff --git a/waterfox/browser/components/sidebar/common/user-operation-blocker.js b/waterfox/browser/components/sidebar/common/user-operation-blocker.js new file mode 100644 index 000000000000..54ad1bc58387 --- /dev/null +++ b/waterfox/browser/components/sidebar/common/user-operation-blocker.js @@ -0,0 +1,109 @@ +/* +# 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'; + +import { + log as internalLogger, + configs +} from './common.js'; +import * as Constants from './constants.js'; +import * as SidebarConnection from './sidebar-connection.js'; +import * as TabsStore from './tabs-store.js'; + +function log(...args) { + internalLogger('common/user-operation-blocker', ...args); +} + +let mBlockingCount = 0; +let mBlockingThrobberCount = 0; +const mProgressbar = document.querySelector('#blocking-screen progress'); + +export function block({ throbber, shade } = {}) { + mBlockingCount++; + document.documentElement.classList.add(Constants.kTABBAR_STATE_BLOCKING); + log('block ', mBlockingCount, () => new Error().stack); + if (throbber) { + mBlockingThrobberCount++; + mProgressbar.delayedShow = setTimeout(() => { + mProgressbar.delayedShow = null; + mProgressbar.classList.add('shown'); + }, configs.delayToShowProgressForBlockedUserOperation); + document.documentElement.classList.add(Constants.kTABBAR_STATE_BLOCKING_WITH_THROBBER); + } + else if (shade) { + document.documentElement.classList.add(Constants.kTABBAR_STATE_BLOCKING_WITH_SHADE); + } +} + +export function setProgress(percentage, windowId = null) { + percentage = Math.max(0, Math.min(100, percentage)); + if (mProgressbar) + mProgressbar.value = percentage; + if (windowId && !TabsStore.getCurrentWindowId()) + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_PROGRESS_USER_OPERATIONS, + windowId, + percentage + }); +} + +export function blockIn(windowId, { throbber, shade } = {}) { + const targetWindow = TabsStore.getCurrentWindowId(); + if (targetWindow && targetWindow != windowId) + return; + + log(`blockIn(${windowId}) `, () => new Error().stack); + if (!targetWindow) { + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_BLOCK_USER_OPERATIONS, + windowId, + throbber: !!throbber, + shade: !!shade + }); + return; + } + block({ throbber, shade }); +} + +export function unblock() { + mBlockingThrobberCount--; + log('unblock ', mBlockingCount, () => new Error().stack); + if (mBlockingThrobberCount < 0) + mBlockingThrobberCount = 0; + if (mBlockingThrobberCount == 0) { + setProgress(0); + mProgressbar.classList.remove('shown'); + if (mProgressbar.delayedShow) + clearTimeout(mProgressbar.delayedShow); + document.documentElement.classList.remove(Constants.kTABBAR_STATE_BLOCKING_WITH_THROBBER); + document.documentElement.classList.remove(Constants.kTABBAR_STATE_BLOCKING_WITH_SHADE); + } + + mBlockingCount--; + if (mBlockingCount < 0) + mBlockingCount = 0; + if (mBlockingCount == 0) + document.documentElement.classList.remove(Constants.kTABBAR_STATE_BLOCKING); +} + +export function unblockIn(windowId, { throbber, shade } = {}) { + const targetWindow = TabsStore.getCurrentWindowId(); + if (targetWindow && targetWindow != windowId) + return; + + log(`unblockIn(${windowId}) `, () => new Error().stack); + if (!targetWindow) { + SidebarConnection.sendMessage({ + type: Constants.kCOMMAND_UNBLOCK_USER_OPERATIONS, + windowId, + throbber: !!throbber, + shade: !!shade + }); + return; + } + unblock({ throbber, shade }); +} + diff --git a/waterfox/browser/components/sidebar/experiments/prefs.js b/waterfox/browser/components/sidebar/experiments/prefs.js new file mode 100644 index 000000000000..d6093465b2d3 --- /dev/null +++ b/waterfox/browser/components/sidebar/experiments/prefs.js @@ -0,0 +1,165 @@ +/* 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/. */ +/* Original: https://github.com/mozilla-extensions/webcompat-addon/blob/main/src/experiment-apis/aboutConfigPrefs.js */ +'use strict'; + +const AboutPreferencesWatcher = { + BASE_URL: null, // this need to be replaced with "moz-extension://..../" + BASE_PREF: 'browser.sidebar.', // null, + + onPrefChanged(name) { + switch (name) { + case 'browser.tabs.selectOwnerOnClose': + Services.prefs.setBoolPref(`${this.BASE_PREF}simulateSelectOwnerOnClose`, Services.prefs.getBoolPref(name)); + break; + + case 'browser.tabs.loadInBackground': + Services.prefs.setBoolPref(`${this.BASE_PREF}simulateTabsLoadInBackgroundInverted`, Services.prefs.getBoolPref(name)); + break; + + case 'browser.tabs.warnOnClose': + Services.prefs.setBoolPref(`${this.BASE_PREF}warnOnCloseTabs`, Services.prefs.getBoolPref(name)); + break; + + case 'browser.tabs.searchclipboardfor.middleclick': + Services.prefs.setBoolPref(`${this.BASE_PREF}middleClickPasteURLOnNewTabButton`, Services.prefs.getBoolPref(name)); + break; + + case 'browser.tabs.insertAfterCurrent': + case 'browser.tabs.insertRelatedAfterCurrent': { + const insertAfterCurrent = Services.prefs.getBoolPref('browser.tabs.insertAfterCurrent'); + const insertRelatedAfterCurrent = Services.prefs.getBoolPref('browser.tabs.insertRelatedAfterCurrent'); + const useTree = ( + Services.prefs.getBoolPref(`${this.BASE_PREF}autoAttach`, false) && + Services.prefs.getBoolPref(`${this.BASE_PREF}syncParentTabAndOpenerTab`, false) + ); + Services.prefs.setStringPref(`${this.BASE_PREF}autoAttachOnOpenedWithOwner`, + !useTree ? -1 : + insertRelatedAfterCurrent ? 5 : + insertAfterCurrent ? 6 : + 0); + Services.prefs.setStringPref(`${this.BASE_PREF}insertNewTabFromPinnedTabAt`, + !useTree ? -1 : + insertRelatedAfterCurrent ? 3 : + insertAfterCurrent ? 0 : + 1); + Services.prefs.setStringPref(`${this.BASE_PREF}insertNewTabFromFirefoxViewAt`, + !useTree ? -1 : + insertRelatedAfterCurrent ? 3 : + insertAfterCurrent ? 0 : + 1); + }; break; + } + }, + + // as an XPCOM component... + classDescription: 'Waterfox Chrome Window Watcher for about:preferences', + contractID: '@waterfox.net/chrome-window-watche-about-preferences;1', + classID: Components.ID('{c8a990cf-b9a3-4b4c-829c-a1dfc5753527}'), + QueryInterface: ChromeUtils.generateQI([ + 'nsIObserver', + 'nsISupportsWeakReference', + ]), + + // nsIObserver + observe(subject, topic, data) { + switch (topic) { + case 'nsPref:changed': + this.onPrefChanged(data); + break; + } + }, + + createInstance(iid) { + return this.QueryInterface(iid); + }, +}; + +this.prefs = class extends ExtensionAPI { + getAPI(context) { + const EventManager = ExtensionCommon.EventManager; + const extensionIDBase = context.extension.id.split('@')[0]; + + AboutPreferencesWatcher.BASE_URL = context.extension.baseURL; + + // Synchronize simulation configs with the browser's preferences + for (const [source, dest] of Object.entries({ + 'browser.tabs.selectOwnerOnClose': `${AboutPreferencesWatcher.BASE_PREF}simulateSelectOwnerOnClose`, + 'browser.tabs.loadInBackground': `${AboutPreferencesWatcher.BASE_PREF}simulateTabsLoadInBackgroundInverted`, + 'browser.tabs.warnOnClose': `${AboutPreferencesWatcher.BASE_PREF}warnOnCloseTabs`, + 'browser.tabs.searchclipboardfor.middleclick': `${AboutPreferencesWatcher.BASE_PREF}middleClickPasteURLOnNewTabButton`, + })) { + Services.prefs.setBoolPref(dest, Services.prefs.getBoolPref(source)); + } + Services.prefs.addObserver('browser.tabs.', AboutPreferencesWatcher); + AboutPreferencesWatcher.onPrefChanged('browser.tabs.insertAfterCurrent'); + + return { + prefs: { + onChanged: new EventManager({ + context, + name: 'prefs.onChanged', + register: (fire) => { + const observe = (_subject, _topic, data) => { + fire.async(data.replace(AboutPreferencesWatcher.BASE_PREF, '')).catch(() => {}); // ignore Message Manager disconnects + }; + Services.prefs.addObserver(AboutPreferencesWatcher.BASE_PREF, observe); + return () => { + Services.prefs.removeObserver(AboutPreferencesWatcher.BASE_PREF, observe); + }; + }, + }).api(), + async getBoolValue(name, defaultValue = false) { + try { + return Services.prefs.getBoolPref(`${AboutPreferencesWatcher.BASE_PREF}${name}`, defaultValue); + } + catch(_error) { + return defaultValue; + } + }, + async setBoolValue(name, value) { + Services.prefs.setBoolPref(`${AboutPreferencesWatcher.BASE_PREF}${name}`, value); + }, + async setDefaultBoolValue(name, value) { + Services.prefs.getDefaultBranch(null).setBoolPref(`${AboutPreferencesWatcher.BASE_PREF}${name}`, value); + }, + async getStringValue(name, defaultValue = '') { + try { + return Services.prefs.getStringPref(`${AboutPreferencesWatcher.BASE_PREF}${name}`, defaultValue); + } + catch(_error) { + return defaultValue; + } + }, + async setStringValue(name, value) { + Services.prefs.setStringPref(`${AboutPreferencesWatcher.BASE_PREF}${name}`, value); + }, + async setDefaultStringValue(name, value) { + Services.prefs.getDefaultBranch(null).setStringPref(`${AboutPreferencesWatcher.BASE_PREF}${name}`, value); + }, + async getIntValue(name, defaultValue = 0) { + try { + return Services.prefs.getIntPref(`${AboutPreferencesWatcher.BASE_PREF}${name}`, defaultValue); + } + catch(_error) { + return defaultValue; + } + }, + async setIntValue(name, value) { + Services.prefs.setIntPref(`${AboutPreferencesWatcher.BASE_PREF}${name}`, value); + }, + async setDefaultIntValue(name, value) { + Services.prefs.getDefaultBranch(null).setIntPref(`${AboutPreferencesWatcher.BASE_PREF}${name}`, value); + }, + }, + }; + } + + onShutdown(isAppShutdown) { + if (isAppShutdown) + return; + + Services.prefs.removeObserver('browser.tabs.', AboutPreferencesWatcher); + } +}; diff --git a/waterfox/browser/components/sidebar/experiments/prefs.json b/waterfox/browser/components/sidebar/experiments/prefs.json new file mode 100644 index 000000000000..1184381aba9d --- /dev/null +++ b/waterfox/browser/components/sidebar/experiments/prefs.json @@ -0,0 +1,199 @@ +[ + { + "namespace": "prefs", + "description": "", + "functions": [ + { + "name": "getBoolValue", + "description": "", + "type": "function", + "parameters": [ + { + "type": "string", + "name": "name", + "description": "" + }, + { + "type": "boolean", + "name": "default", + "optional": true, + "description": "" + } + ], + "returns": { + "type": "boolean", + "description": "" + }, + "async": true + }, + { + "name": "setBoolValue", + "description": "", + "type": "function", + "parameters": [ + { + "type": "string", + "name": "name", + "description": "" + }, + { + "type": "boolean", + "name": "value", + "description": "" + } + ], + "async": true + }, + { + "name": "setDefaultBoolValue", + "description": "", + "type": "function", + "parameters": [ + { + "type": "string", + "name": "name", + "description": "" + }, + { + "type": "boolean", + "name": "value", + "description": "" + } + ], + "async": true + }, + { + "name": "getStringValue", + "description": "", + "type": "function", + "parameters": [ + { + "type": "string", + "name": "name", + "description": "" + }, + { + "type": "string", + "name": "default", + "optional": true, + "description": "" + } + ], + "returns": { + "type": "string", + "description": "" + }, + "async": true + }, + { + "name": "setStringValue", + "description": "", + "type": "function", + "parameters": [ + { + "type": "string", + "name": "name", + "description": "" + }, + { + "type": "string", + "name": "value", + "description": "" + } + ], + "async": true + }, + { + "name": "setDefaultStringValue", + "description": "", + "type": "function", + "parameters": [ + { + "type": "string", + "name": "name", + "description": "" + }, + { + "type": "string", + "name": "value", + "description": "" + } + ], + "async": true + }, + { + "name": "getIntValue", + "description": "", + "type": "function", + "parameters": [ + { + "type": "string", + "name": "name", + "description": "" + }, + { + "type": "integer", + "name": "default", + "optional": true, + "description": "" + } + ], + "returns": { + "type": "integer", + "description": "" + }, + "async": true + }, + { + "name": "setIntValue", + "description": "", + "type": "function", + "parameters": [ + { + "type": "string", + "name": "name", + "description": "" + }, + { + "type": "integer", + "name": "value", + "description": "" + } + ], + "async": true + }, + { + "name": "setDefaultIntValue", + "description": "", + "type": "function", + "parameters": [ + { + "type": "string", + "name": "name", + "description": "" + }, + { + "type": "integer", + "name": "value", + "description": "" + } + ], + "async": true + } + ], + "events": [ + { + "name": "onChanged", + "description": "", + "type": "function", + "parameters": [ + { + "type": "string", + "name": "name", + "description": "" + } + ] + } + ] + } +] \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/experiments/syncPrefs.js b/waterfox/browser/components/sidebar/experiments/syncPrefs.js new file mode 100644 index 000000000000..8cef46fce96c --- /dev/null +++ b/waterfox/browser/components/sidebar/experiments/syncPrefs.js @@ -0,0 +1,41 @@ +/* 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/. */ +/* Original: https://github.com/mozilla-extensions/webcompat-addon/blob/main/src/experiment-apis/aboutConfigPrefsChild.js */ +'use strict'; + +this.syncPrefs = class extends ExtensionAPI { + getAPI(context) { + const extensionIDBase = context.extension.id.split('@')[0]; + const extensionPrefNameBase = `extensions.${extensionIDBase}.`; + + return { + syncPrefs: { + getBoolValue(name, defaultValue = false) { + try { + return Services.prefs.getBoolPref(`${extensionPrefNameBase}${name}`, defaultValue); + } + catch(_error) { + return defaultValue; + } + }, + getStringValue(name, defaultValue = '') { + try { + return Services.prefs.getStringPref(`${extensionPrefNameBase}${name}`, defaultValue); + } + catch(_error) { + return defaultValue; + } + }, + getIntValue(name, defaultValue = 0) { + try { + return Services.prefs.getIntPref(`${extensionPrefNameBase}${name}`, defaultValue); + } + catch(_error) { + return defaultValue; + } + }, + }, + }; + } +}; diff --git a/waterfox/browser/components/sidebar/experiments/syncPrefs.json b/waterfox/browser/components/sidebar/experiments/syncPrefs.json new file mode 100644 index 000000000000..efd05f00be08 --- /dev/null +++ b/waterfox/browser/components/sidebar/experiments/syncPrefs.json @@ -0,0 +1,76 @@ +[ + { + "namespace": "syncPrefs", + "description": "", + "functions": [ + { + "name": "getBoolValue", + "description": "", + "type": "function", + "parameters": [ + { + "type": "string", + "name": "name", + "description": "" + }, + { + "type": "boolean", + "name": "default", + "optional": true, + "description": "" + } + ], + "returns": { + "type": "boolean", + "description": "" + } + }, + { + "name": "getStringValue", + "description": "", + "type": "function", + "parameters": [ + { + "type": "string", + "name": "name", + "description": "" + }, + { + "type": "string", + "name": "default", + "optional": true, + "description": "" + } + ], + "returns": { + "type": "string", + "description": "" + } + }, + { + "name": "getIntValue", + "description": "", + "type": "function", + "parameters": [ + { + "type": "string", + "name": "name", + "description": "" + }, + { + "type": "integer", + "name": "default", + "optional": true, + "description": "" + } + ], + "returns": { + "type": "integer", + "description": "" + } + } + ], + "events": [ + ] + } +] \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/experiments/waterfoxBridge.js b/waterfox/browser/components/sidebar/experiments/waterfoxBridge.js new file mode 100644 index 000000000000..3e370d7f9bf0 --- /dev/null +++ b/waterfox/browser/components/sidebar/experiments/waterfoxBridge.js @@ -0,0 +1,887 @@ +/* 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'; + +const HTML = 'http://www.w3.org/1999/xhtml'; +const XUL = 'http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'; + +const TYPE_TREE = 'application/x-ws-tree'; +const TST_ID = 'treestyletab@piro.sakura.ne.jp'; + +const lazy = {}; +ChromeUtils.defineESModuleGetters(lazy, { + AddonManager: 'resource://gre/modules/AddonManager.sys.mjs', + CustomizableUI: 'resource:///modules/CustomizableUI.sys.mjs', + ExtensionPermissions: 'resource://gre/modules/ExtensionPermissions.sys.mjs', + PageThumbs: 'resource://gre/modules/PageThumbs.sys.mjs', + PlacesUtils: 'resource://gre/modules/PlacesUtils.sys.mjs', +}); + +// Range.createContextualFragment() unexpectedly drops XUL elements. +// Moreover, the security mechanism of the browser rejects adoptation of elements +// created by DOMParser(). Thus we need to create elements manually... +function element(document, NS, localName, attributes, children) { + if (Array.isArray(attributes)) { + children = attributes; + attributes = {}; + } + const element = document.createElementNS(NS, localName); + if (attributes) { + for (const [name, value] of Object.entries(attributes)) { + element.setAttribute(name, value); + } + } + if (children) { + for (const child of children) { + if (typeof child == 'string') + element.appendChild(document.createTextNode(child)); + else + element.appendChild(child); + } + } + return element; +} + +const BrowserWindowWatcher = { + WATCHING_URLS: [ + 'chrome://browser/content/browser.xhtml', + ], + BASE_URL: null, // this need to be replaced with "moz-extension://..../" + BASE_PREF: 'browser.sidebar.', // null, + locale: null, // this need to be replaced with a map + loadingForbiddenURLs: [], + autoplayBlockedListeners: new Set(), + autoplayUnblockedListeners: new Set(), + visibilityChangedListeners: new Set(), + menuCommandListeners: new Set(), + sidebarShownListeners: new Set(), + sidebarHiddenListeners: new Set(), + lastTransferredFiles: new Map(), + + handleWindow(win) { + if (!win || + !win.location) + return false; + + const document = win.document; + if (!document) + return false; + + if (win.location.href.startsWith('chrome://browser/content/browser.xhtml')) { + const installed = this.installTabsSidebar(win); + if (installed) { + win.addEventListener('DOMAudioPlaybackBlockStarted', this, { capture: true }); + win.addEventListener('DOMAudioPlaybackBlockStopped', this, { capture: true }); + win.addEventListener('visibilitychange', this); + win.addEventListener('TreeVerticalTabsShown', this); + win.addEventListener('TreeVerticalTabsHidden', this); + } + return installed; + } + + return true; + }, + + unhandleWindow(win) { + if (!win || + !win.location) + return; + + const document = win.document; + if (!document) + return; + + if (win.location.href.startsWith('chrome://browser/content/browser.xhtml')) { + this.uninstallTabsSidebar(win); + try { + win.removeEventListener('DOMAudioPlaybackBlockStarted', this, { capture: true }); + win.removeEventListener('DOMAudioPlaybackBlockStopped', this, { capture: true }); + win.removeEventListener('visibilitychange', this); + win.removeEventListener('TreeVerticalTabsShown', this); + win.removeEventListener('TreeVerticalTabsHidden', this); + } + catch(_error) { + } + } + }, + + installTabsSidebar(win) { + const document = win.document; + + const tabsSidebarElement = document.querySelector('#tree-vertical-tabs-box'); + if (tabsSidebarElement?.getAttribute('initialized') == 'true') + return true; + + if (tabsSidebarElement) { + tabsSidebarElement.setAttribute('initialized', 'true'); + tabsSidebarElement.addEventListener('dragover', this, { capture: true }); + } else { + console.error('WaterfoxBridge: #tree-vertical-tabs element not found. Cannot attach event listeners or load panel.'); + } + + document.addEventListener('command', this); + document.addEventListener('customizationchange', this, { capture: true }); + + this.updateToggleButton(document); + + return true; + }, + + getKeyFromFile(file) { + if (!file) + return ''; + return `${file.name}?lastModified=${file.lastModified}&size=${file.size}&type=${file.type}`; + }, + getFileURL(file) { + if (!file) + return ''; + return this.lastTransferredFiles[this.getKeyFromFile(file)]; + }, + + uninstallTabsSidebar(win) { + const document = win.document; + + document.removeEventListener('command', this); + document.removeEventListener('customizationchange', this, { capture: true }); + + const tabsSidebarElement = document.querySelector('#tree-vertical-tabs-box'); + if (tabsSidebarElement?.getAttribute('initialized') == 'true') { + tabsSidebarElement.removeAttribute('initialized'); + tabsSidebarElement.removeEventListener('dragover', this, { capture: true }); + } + }, + + updateToggleButton(document, button) { + button = button || document.querySelector('#toggle-tree-vertical-tabs'); + if (!button) + return; + + button.removeAttribute('disabled'); + }, + + *iterateTargetWindows() { + const browserWindows = Services.wm.getEnumerator('navigator:browser'); + while (browserWindows.hasMoreElements()) { + const win = browserWindows.getNext()/*.QueryInterface(Components.interfaces.nsIDOMWindow)*/ + yield win; + } + return; + }, + + openOptions(win, full = false) { + const url = full ? `${this.BASE_URL}options/options.html#!` : 'about:preferences#tabsSidebar'; + + const windows = Services.wm.getEnumerator('navigator:browser'); + while (windows.hasMoreElements()) { + const win = windows.getNext()/*.QueryInterface(Components.interfaces.nsIDOMWindow)*/; + if (!win.gBrowser) + continue; + for (const tab of win.gBrowser.tabs) { + if (tab.linkedBrowser.currentURI.spec != url) + continue; + win.gBrowser.selectedTab = tab; + return; + } + } + + (win || Services.wm.getMostRecentBrowserWindow()) + .openLinkIn(url, 'tab', { + allowThirdPartyFixup: false, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + inBackground: false, + }); + }, + + handleEvent(event) { + const win = event.target.ownerDocument?.defaultView || event.target.defaultView; + switch (event.type) { + case 'command': + switch (event.target.id) { + case 'toggle-tree-vertical-tabs': + case 'toggle-tree-vertical-tabs-command': + case 'viewmenu-toggle-tree-vertical-tabs': + this.updateToggleButton(event.target.ownerDocument); + break; + } + break; + + case 'customizationchange': + this.updateToggleButton(event.target.ownerDocument); + break; + + case 'DOMAudioPlaybackBlockStarted': { + const gBrowser = event.target.ownerDocument.defaultView.gBrowser; + const tab = gBrowser.getTabForBrowser(event.target); + for (const listener of this.autoplayBlockedListeners) { + listener(tab); + } + }; break; + + case 'DOMAudioPlaybackBlockStopped': { + const gBrowser = event.target.ownerDocument.defaultView.gBrowser; + const tab = gBrowser.getTabForBrowser(event.target); + for (const listener of this.autoplayUnblockedListeners) { + listener(tab); + } + }; break; + + case 'visibilitychange': + for (const listener of this.visibilityChangedListeners) { + listener(event.currentTarget); + } + break; + + case 'TreeVerticalTabsShown': + for (const listener of this.sidebarShownListeners) { + listener(event.target.ownerDocument.defaultView); + } + break; + + case 'TreeVerticalTabsHidden': + for (const listener of this.sidebarHiddenListeners) { + listener(event.target.ownerDocument.defaultView); + } + break; + + case 'dragover': { + const tabsSidebarElement = event.currentTarget; + this.lastTransferredFiles.clear(); + for (const file of event.dataTransfer.files) { + const fileInternal = Cc['@mozilla.org/file/local;1'] + .createInstance(Components.interfaces.nsIFile); + fileInternal.initWithPath(file.mozFullPath); + const url = Services.io.getProtocolHandler('file') + .QueryInterface(Components.interfaces.nsIFileProtocolHandler) + .getURLSpecFromActualFile(fileInternal); + this.lastTransferredFiles[this.getKeyFromFile(file)] = url; + } + }; break; + } + }, + tryHidePopup(event) { + if (event.target.closest) + event.target.closest('panel')?.hidePopup(); + }, + + // as an XPCOM component... + classDescription: 'Waterfox Chrome Window Watcher for Browser Windows', + contractID: '@waterfox.net/chrome-window-watche-browser-windows;1', + classID: Components.ID('{8d25e5cc-1d67-4556-819e-e25bd37c79c5}'), + QueryInterface: ChromeUtils.generateQI([ + 'nsIContentPolicy', + 'nsIObserver', + 'nsISupportsWeakReference', + ]), + + // nsIContentPolicy + shouldLoad(contentLocation, loadInfo, mimeTypeGuess) { + const FORBIDDEN_URL_MATCHER = /^about:blank\?forbidden-url=/; + if (FORBIDDEN_URL_MATCHER.test(contentLocation.spec)) { + const url = contentLocation.spec.replace(FORBIDDEN_URL_MATCHER, ''); + const index = this.loadingForbiddenURLs.indexOf(url); + if (index > -1) { + this.loadingForbiddenURLs.splice(index, 1); + const browser = loadInfo.browsingContext.embedderElement; + browser.loadURI(Services.io.newURI(url), { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + return Components.interfaces.nsIContentPolicy.REJECT_REQUEST; + } + } + + if (this.WATCHING_URLS.some(url => contentLocation.spec.startsWith(url))) { + const startAt = Date.now(); + const topWin = loadInfo.browsingContext.topChromeWindow; + const timer = topWin.setInterval(() => { + if (Date.now() - startAt > 1000) { + // timeout + topWin.clearInterval(timer); + return; + } + const win = loadInfo.browsingContext.window; + if (!win) + return; + try { + if (this.handleWindow(win)) + topWin.clearInterval(timer); + } + catch(_error) { + } + }, 250); + } + return Components.interfaces.nsIContentPolicy.ACCEPT; + }, + + shouldProcess(contentLocation, loadInfo, mimeTypeGuess) { + return Components.interfaces.nsIContentPolicy.ACCEPT; + }, + + // nsIObserver + observe(subject, topic, data) { + switch (topic) { + case 'domwindowopened': + subject + //.QueryInterface(Components.interfaces.nsIDOMWindow) + .addEventListener('DOMContentLoaded', () => { + this.handleWindow(subject); + }, { once: true }); + break; + } + }, + + createInstance(iid) { + return this.QueryInterface(iid); + }, + + + // AddonManager listener callbacks + + async tryConfirmUsingTST() { + const ignorePrefKey = `${this.BASE_PREF}.ignoreConflictionWithTST`; + if (Services.prefs.getBoolPref(ignorePrefKey, false)) + return; + + const nsIPrompt = Components.interfaces.nsIPrompt; + const shouldAsk = { value: true }; + const result = Services.prompt.confirmEx( + Services.wm.getMostRecentBrowserWindow(), + this.locale.get('tryConfirmUsingTST_title'), + this.locale.get('tryConfirmUsingTST_message'), + (nsIPrompt.BUTTON_TITLE_IS_STRING * nsIPrompt.BUTTON_POS_0 | + nsIPrompt.BUTTON_TITLE_IS_STRING * nsIPrompt.BUTTON_POS_1 | + nsIPrompt.BUTTON_TITLE_IS_STRING * nsIPrompt.BUTTON_POS_2), + this.locale.get('tryConfirmUsingTST_WS'), + this.locale.get('tryConfirmUsingTST_both'), + this.locale.get('tryConfirmUsingTST_TST'), + this.locale.get('tryConfirmUsingTST_ask'), + shouldAsk + ); + + if (result > -1 && + !shouldAsk.value) + Services.prefs.setBoolPref(ignorePrefKey, true); + + switch (result) { + case 0: { + const addon = await lazy.AddonManager.getAddonByID(TST_ID); + addon.disable(); + }; return; + + case 2: + Services.prefs.setBoolPref('browser.sidebar.enabled', false); + return; + + default: + return; + } + }, + + // install listener callbacks + onNewInstall(_install) {}, + onInstallCancelled(_install) {}, + onInstallPostponed(_install) {}, + onInstallFailed(_install) {}, + onInstallEnded(install) { + if (install.addon.id == TST_ID) + this.tryConfirmUsingTST(); + }, + onDownloadStarted(_install) {}, + onDownloadCancelled(_install) {}, + onDownloadEnded(_install) {}, + onDownloadFailed(_install) {}, + + // addon listener callbacks + onUninstalled(_addon) {}, + onEnabled(addon) { + if (addon.id == TST_ID) + this.tryConfirmUsingTST(); + }, + onDisabled(_addon) {}, +}; + +this.waterfoxBridge = class extends ExtensionAPI { + getAPI(context) { + const EventManager = ExtensionCommon.EventManager; + + return { + waterfoxBridge: { + async initUI() { + BrowserWindowWatcher.EXTENSION_ID = context.extension.id; + BrowserWindowWatcher.BASE_URL = context.extension.baseURL; + //BrowserWindowWatcher.BASE_PREF = `extensions.${context.extension.id.split('@')[0]}.`; + BrowserWindowWatcher.locale = { + get(key) { + key = key.toLowerCase(); + if (this.selected.has(key)) + return this.selected.get(key); + return this.default.get(key) || key; + }, + default: context.extension.localeData.messages.get(context.extension.localeData.defaultLocale), + selected: context.extension.localeData.messages.get(context.extension.localeData.selectedLocale), + }; + + //const resourceURI = Services.io.newURI('resources', null, context.extension.rootURI); + //const handler = Cc['@mozilla.org/network/protocol;1?name=resource'].getService(Components.interfaces.nsISubstitutingProtocolHandler); + //handler.setSubstitution('waterfox-bridge', resourceURI); + + // watch loading of about:preferences in subframes + const registrar = Components.manager.QueryInterface(Components.interfaces.nsIComponentRegistrar); + registrar.registerFactory( + BrowserWindowWatcher.classID, + BrowserWindowWatcher.classDescription, + BrowserWindowWatcher.contractID, + BrowserWindowWatcher + ); + Services.catMan.addCategoryEntry( + 'content-policy', + BrowserWindowWatcher.contractID, + BrowserWindowWatcher.contractID, + false, + true + ); + + // handle loading of browser windows + Services.ww.registerNotification(BrowserWindowWatcher); + + // handle already opened browser windows + const windows = BrowserWindowWatcher.iterateTargetWindows(); + while (true) { + const win = windows.next(); + if (win.done) + break; + BrowserWindowWatcher.handleWindow(win.value); + } + + // grant special permissions by default + if (!Services.prefs.getBoolPref(`${BrowserWindowWatcher.BASE_PREF}permissionsGranted`, false)) { + lazy.ExtensionPermissions.add(context.extension.id, { + origins: [''], + permissions: ['internal:privateBrowsingAllowed'], + }, true); + Services.prefs.setBoolPref(`${BrowserWindowWatcher.BASE_PREF}permissionsGranted`, true); + } + + // auto detection and warning for TST + lazy.AddonManager.addInstallListener(BrowserWindowWatcher); + lazy.AddonManager.addAddonListener(BrowserWindowWatcher); + const installedTST = await lazy.AddonManager.getAddonByID(TST_ID); + if (installedTST?.isActive) + BrowserWindowWatcher.tryConfirmUsingTST(); + }, + + async reserveToLoadForbiddenURL(url) { + BrowserWindowWatcher.loadingForbiddenURLs.push(url); + }, + + async getFileURL(file) { + return BrowserWindowWatcher.getFileURL(file); + }, + + async getTabPreview(tabId) { + const info = { + url: null, + found: false, + }; + const tab = context.extension.tabManager.get(tabId); + if (!tab) + return info; + + const nativeTab = tab.nativeTab; + const window = nativeTab.ownerDocument.defaultView; + try { + const canvas = await window.tabPreviews.get(nativeTab); + /* + // We can get a URL like "https%3A%2F%2Fwww.example.com.org%2F&revision=0000" + // but Firefox does not allow loading of such a special internal URL from + // addon's sidebar page. + const image = await window.tabPreviews.get(nativeTab); + return image.src; + */ + if (canvas) { + info.url = canvas.toDataURL('image/png'); + info.found = true; + return info; + } + } + catch (_error) { // tabPreviews.capture() raises error if the tab is discarded. + // console.error('waterfoxBridge: failed to take a tab preview: ', tabId, error); + } + + // simulate default preview + // see also: https://searchfox.org/mozilla-esr115/rev/d0623081f317c92e0c7bc2a8b1b138687bdb23f5/browser/themes/shared/ctrlTab.css#85-94 + const canvas = lazy.PageThumbs.createCanvas(window); + try { + // TODO: we should change the fill color to "CanvasText"... + const image = new window.Image(); + await new Promise((resolve, reject) => { + image.addEventListener('load', resolve, { once: true }); + image.addEventListener('error', reject, { once: true }); + image.src = 'chrome://global/skin/icons/defaultFavicon.svg'; + }); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = 'Canvas'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + const iconSize = canvas.width * 0.2; + ctx.drawImage( + image, + 0, + 0, + image.width, + image.height, + (canvas.width - iconSize) / 2, + (canvas.height - iconSize) / 2, + iconSize, + iconSize + ); + } + catch (_error) { + } + info.url = canvas.toDataURL('image/png'); + return info; + }, + + async showPreviewPanel(tabId, top) { + const tab = tabId && context.extension.tabManager.get(tabId); + if (!tab) + return; + + const document = tab.nativeTab.ownerDocument; + const tabbrowserTabs = document.getElementById('tabbrowser-tabs'); + if (!tabbrowserTabs) + return; + + if (!tabbrowserTabs.previewPanel) { + // load the tab preview component + const TabHoverPreviewPanel = ChromeUtils.importESModule( + 'chrome://browser/content/tabbrowser/tab-hover-preview.mjs' + ).default; + tabbrowserTabs.previewPanel = new TabHoverPreviewPanel( + document.getElementById('tab-preview-panel') + ); + } + tabbrowserTabs.previewPanel.__ws__top = top; + tabbrowserTabs.previewPanel.activate(tab.nativeTab); + }, + + async hidePreviewPanel(windowId) { + const win = windowId && context.extension.windowManager.get(windowId); + if (!win || !win.window) + return; + + try { + // Access the document through the window object + const document = win.window.document; + const tabPreview = document.getElementById('tabbrowser-tabs')?.previewPanel; + if (!tabPreview) + return; + tabPreview.__ws__top = null; + } catch (error) { + console.log("Error in hidePreviewPanel:", error); + } + }, + + async openPreferences() { + BrowserWindowWatcher.openOptions(); + }, + + onWindowVisibilityChanged: new EventManager({ + context, + name: 'waterfoxBridge.onWindowVisibilityChanged', + register: (fire) => { + const onChanged = win => { + const wrappedWindow = context.extension.windowManager.getWrapper(win); + if (wrappedWindow) + fire.async(wrappedWindow.id, win.document.visibilityState).catch(() => {}); // ignore Message Manager disconnects + }; + BrowserWindowWatcher.visibilityChangedListeners.add(onChanged); + return () => { + BrowserWindowWatcher.visibilityChangedListeners.delete(onChanged); + }; + }, + }).api(), + + onMenuCommand: new EventManager({ + context, + name: 'waterfoxBridge.onMenuCommand', + register: (fire) => { + const onCommand = event => { + fire.async({ + itemId: event.target.id, + detail: event.detail, + button: event.button, + altKey: event.altKey, + ctrlKey: event.ctrlKey, + metaKey: event.metaKey, + shiftKey: event.shiftKey, + }).catch(() => {}); // ignore Message Manager disconnects + }; + BrowserWindowWatcher.menuCommandListeners.add(onCommand); + return () => { + BrowserWindowWatcher.menuCommandListeners.delete(onCommand); + }; + }, + }).api(), + + onSidebarShown: new EventManager({ + context, + name: 'waterfoxBridge.onSidebarShown', + register: (fire) => { + const onShown = win => { + const wrappedWindow = context.extension.windowManager.getWrapper(win); + if (wrappedWindow) + fire.async(wrappedWindow.id).catch(() => {}); // ignore Message Manager disconnects + }; + BrowserWindowWatcher.sidebarShownListeners.add(onShown); + return () => { + BrowserWindowWatcher.sidebarShownListeners.delete(onShown); + }; + }, + }).api(), + + onSidebarHidden: new EventManager({ + context, + name: 'waterfoxBridge.onSidebarHidden', + register: (fire) => { + const onHidden = win => { + const wrappedWindow = context.extension.windowManager.getWrapper(win); + if (wrappedWindow) + fire.async(wrappedWindow.id).catch(() => {}); // ignore Message Manager disconnects + }; + BrowserWindowWatcher.sidebarHiddenListeners.add(onHidden); + return () => { + BrowserWindowWatcher.sidebarHiddenListeners.delete(onHidden); + }; + }, + }).api(), + + + async listSyncDevices() { + const devices = []; + const targets = Services.wm.getMostRecentBrowserWindow().gSync.getSendTabTargets(); + for (const target of targets) { + devices.push({ + id: target.id, + name: target.name, + type: target.type, + }); + } + return devices; + }, + + async sendToDevice(tabIds, deviceId) { + if (!Array.isArray(tabIds)) + tabIds = [tabIds]; + const gSync = Services.wm.getMostRecentBrowserWindow().gSync; + const tabs = tabIds.map(id => context.extension.tabManager.get(id)); + const targets = gSync.getSendTabTargets().filter(target => !deviceId || target.id == deviceId); + for (const tab of tabs) { + gSync.sendTabToDevice( + tab.nativeTab.linkedBrowser.currentURI.spec, + targets, + tab.nativeTab.linkedBrowser.contentTitle + ); + } + }, + + async openSyncDeviceSettings(windowId) { + let DOMWin = null; + try { + const win = windowId && context.extension.windowManager.get(windowId) + DOMWin = win?.window; + } + catch (_error) { + } + (DOMWin || Services.wm.getMostRecentBrowserWindow()).gSync.openDevicesManagementPage('sendtab'); + }, + + + async listSharingServices(tabId) { + const tab = tabId && context.extension.tabManager.get(tabId); + + const services = []; + const win = Services.wm.getMostRecentBrowserWindow(); + const sharingService = win.gBrowser.MacSharingService; + if (!sharingService) + return services; + + const uri = win.gURLBar.makeURIReadable( + tab?.nativeTab.linkedBrowser.currentURI || + Services.io.newURI('https://waterfox.net/', null, null) + ).displaySpec; + for (const service of sharingService.getSharingProviders(uri)) { + services.push({ + name: service.name, + title: service.menuItemTitle, + image: service.image, + }); + } + return services; + }, + + async share(tabIds, shareName) { + if (!Array.isArray(tabIds)) + tabIds = [tabIds]; + const tabs = tabIds.map(id => context.extension.tabManager.get(id)); + + // currently we can share only one URL at a time... + const tab = tabs[0]; + const win = Services.wm.getMostRecentBrowserWindow(); + const uri = win.gURLBar.makeURIReadable(tab.nativeTab.linkedBrowser.currentURI).displaySpec; + + if (AppConstants.platform == 'win') { + win.WindowsUIUtils.shareUrl(uri, tab.nativeTab.linkedBrowser.contentTitle); + return; + } + + if (shareName) { // for macOS + win.gBrowser.MacSharingService.shareUrl(shareName, uri, tab.nativeTab.linkedBrowser.contentTitle); + return; + } + }, + + async openSharingPreferences() { + Services.wm.getMostRecentBrowserWindow().gBrowser.MacSharingService.openSharingPreferences(); + }, + + + async listAutoplayBlockedTabs(windowId) { + const tabs = new Set(); + const windows = windowId ? + [context.extension.windowManager.get(windowId)] : + context.extension.windowManager.getAll(); + for (const win of windows) { + if (!win.window.gBrowser) + continue; + for (const tab of win.window.document.querySelectorAll('tab[activemedia-blocked="true"]')) { + const wrappedTab = context.extension.tabManager.getWrapper(tab); + if (wrappedTab) + tabs.add(wrappedTab.convert()); + } + } + return [...tabs].sort((a, b) => a.index - b.index); + }, + + async isAutoplayBlockedTab(tabId) { + const tab = context.extension.tabManager.get(tabId); + if (!tab) + return false; + return tab.nativeTab.getAttribute('activemedia-blocked') == 'true'; + }, + + async unblockAutoplay(tabIds) { + if (!Array.isArray(tabIds)) + tabIds = [tabIds]; + const tabs = tabIds.map(id => context.extension.tabManager.get(id)); + for (const tab of tabs) { + tab.nativeTab.linkedBrowser.resumeMedia(); + } + }, + + onAutoplayBlocked: new EventManager({ + context, + name: 'waterfoxBridge.onAutoplayBlocked', + register: (fire) => { + const onBlocked = tab => { + const wrappedTab = context.extension.tabManager.getWrapper(tab); + if (wrappedTab) + fire.async(wrappedTab.convert()).catch(() => {}); // ignore Message Manager disconnects + }; + BrowserWindowWatcher.autoplayBlockedListeners.add(onBlocked); + return () => { + BrowserWindowWatcher.autoplayBlockedListeners.delete(onBlocked); + }; + }, + }).api(), + + onAutoplayUnblocked: new EventManager({ + context, + name: 'waterfoxBridge.onAutoplayUnblocked', + register: (fire) => { + const onUnblocked = tab => { + const wrappedTab = context.extension.tabManager.getWrapper(tab); + if (wrappedTab) + fire.async(wrappedTab.convert()).catch(() => {}); // ignore Message Manager disconnects + }; + BrowserWindowWatcher.autoplayUnblockedListeners.add(onUnblocked); + return () => { + BrowserWindowWatcher.autoplayUnblockedListeners.delete(onUnblocked); + }; + }, + }).api(), + + + async isSelectionClipboardAvailable() { + try { + return Services.clipboard.isClipboardTypeSupported(Services.clipboard.kSelectionClipboard); + } + catch(_error) { + return false; + } + }, + + async getSelectionClipboardContents() { + try { + const transferable = Components.classes['@mozilla.org/widget/transferable;1'] + .createInstance(Components.interfaces.nsITransferable); + const loadContext = Services.wm.getMostRecentBrowserWindow() + .docShell.QueryInterface(Components.interfaces.nsILoadContext); + transferable.init(loadContext); + transferable.addDataFlavor('text/plain'); + + Services.clipboard.getData(transferable, Services.clipboard.kSelectionClipboard); + + const data = {}; + transferable.getTransferData('text/plain', data); + if (data) { + data = data.value.QueryInterface(Components.interfaces.nsISupportsString); + return data.data; + } + } + catch(_error) { + return ''; + } + }, + }, + }; + } + + onShutdown(isAppShutdown) { + if (isAppShutdown) + return; + + lazy.AddonManager.removeInstallListener(BrowserWindowWatcher); + lazy.AddonManager.removeAddonListener(BrowserWindowWatcher); + + if (lazy.PlacesUtils.__ws_orig__unwrapNodes) { + lazy.PlacesUtils.unwrapNodes = lazy.PlacesUtils.__ws_orig__unwrapNodes; + lazy.PlacesUtils.__ws_orig__unwrapNodes = null; + } + + const registrar = Components.manager.QueryInterface(Components.interfaces.nsIComponentRegistrar); + registrar.unregisterFactory( + BrowserWindowWatcher.classID, + BrowserWindowWatcher + ); + Services.catMan.deleteCategoryEntry( + 'content-policy', + BrowserWindowWatcher.contractID, + false + ); + + Services.ww.unregisterNotification(BrowserWindowWatcher); + + const windows = BrowserWindowWatcher.iterateTargetWindows(); + while (true) { + const win = windows.next(); + if (win.done) + break; + BrowserWindowWatcher.unhandleWindow(win.value); + } + + //const handler = Cc['@mozilla.org/network/protocol;1?name=resource'].getService(Components.interfaces.nsISubstitutingProtocolHandler); + //handler.setSubstitution('waterfox-bridge', null); + + Services.prefs.removeObserver('', BrowserWindowWatcher); + } +}; diff --git a/waterfox/browser/components/sidebar/experiments/waterfoxBridge.json b/waterfox/browser/components/sidebar/experiments/waterfoxBridge.json new file mode 100644 index 000000000000..38cb4c876029 --- /dev/null +++ b/waterfox/browser/components/sidebar/experiments/waterfoxBridge.json @@ -0,0 +1,459 @@ +[ + { + "namespace": "waterfoxBridge", + + "types": [ + { + "id": "TabPreviewInfo", + "type": "object", + "description": "Information of a tab preview image.", + "properties": { + "url": { + "type": "string", + "description": "A URL string to a tab preview image." + }, + "found": { + "type": "boolean", + "description": "Indicates availability of the preview image." + } + } + }, + { + "id": "SyncDeviceInfo", + "type": "object", + "description": "An object indicating sync device.", + "properties": { + "id": { + "type": "string", + "description": "The identifier string of a sync device." + }, + "name": { + "type": "string", + "description": "The visible name of a sync device." + }, + "target": { + "type": "string", + "description": "The type string of a sync device." + } + } + }, + { + "id": "ShareService", + "type": "object", + "description": "An object indicating sync device.", + "properties": { + "name": { + "type": "string", + "description": "The name of a sharing service." + }, + "title": { + "type": "string", + "description": "The menu label for a sharing service." + }, + "image": { + "type": "string", + "description": "The image for a sharing device." + } + } + } + ], + + "functions": [ + { + "name": "initUI", + "type": "function", + "description": "Initializes the custom UI for Waterfox.", + "async": true, + "parameters": [] + }, + { + "name": "reserveToLoadForbiddenURL", + "type": "function", + "description": "Reserve a URL to load by Waterfox.", + "async": true, + "parameters": [ + { + "type": "string", + "name": "url" + } + ] + }, + { + "name": "getFileURL", + "type": "function", + "description": "Get file: URL from a file.", + "async": true, + "parameters": [ + { + "name": "file", + "type": "object", + "description": "Information of a file.", + "properties": { + "lastModified": { + "type": "integer", + "description": "Last modified timestamp of the file." + }, + "name": { + "type": "string", + "description": "Name of the file." + }, + "size": { + "type": "integer", + "description": "Size of the file." + }, + "type": { + "type": "string", + "description": "MIME type of the file." + } + } + } + ], + "returns": { + "type": "string", + "name": "url" + } + }, + { + "name": "getTabPreview", + "type": "function", + "description": "Returns a tab preview image URL and its metadata.", + "async": true, + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0 + } + ], + "returns": { + "type": "array", + "items": { + "$ref": "waterfoxBridge.TabPreviewInfo" + } + } + }, + { + "name": "showPreviewPanel", + "type": "function", + "description": "Shows the preview panel for the tab.", + "async": true, + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0 + }, + { + "type": "integer", + "name": "top" + } + ] + }, + { + "name": "hidePreviewPanel", + "type": "function", + "description": "Hides the preview panel for the window.", + "async": true, + "parameters": [ + { + "type": "integer", + "name": "windowId", + "minimum": 0 + } + ] + }, + { + "name": "openPreferences", + "type": "function", + "description": "Opens the preferences.", + "async": true, + "parameters": [] + }, + + { + "name": "listSyncDevices", + "type": "function", + "description": "Returns an array of sync device information.", + "async": true, + "parameters": [], + "returns": { + "type": "array", + "items": { + "$ref": "waterfoxBridge.SyncDeviceInfo" + } + } + }, + { + "name": "sendToDevice", + "type": "function", + "description": "Sends tabs to a device or all devices.", + "async": true, + "parameters": [ + { + "name": "tabIds", + "description": "The tab or list of tabs to send.", + "type": "array", + "choices": [ + { "type": "integer", "minimum": 0 }, + { "type": "array", "items": { "type": "integer", "minimum": 0 } } + ] + }, + { + "name": "deviceId", + "type": "string", + "description": "The ID of a device", + "optional": true + } + ] + }, + { + "name": "openSyncDeviceSettings", + "type": "function", + "description": "Opens the settings of sync devices.", + "async": true, + "parameters": [ + { + "type": "integer", + "name": "windowId", + "minimum": 0, + "optional": true + } + ] + }, + + { + "name": "listSharingServices", + "type": "function", + "description": "Returns an array of share service on macOS.", + "async": true, + "parameters": [ + { + "type": "integer", + "name": "tabId", + "minimum": 0, + "optional": true + } + ], + "returns": { + "type": "array", + "items": { + "$ref": "waterfoxBridge.ShareService" + } + } + }, + { + "name": "share", + "type": "function", + "description": "Shares tabs via browser's share feature.", + "async": true, + "parameters": [ + { + "name": "tabIds", + "description": "The tab or list of tabs to share.", + "type": "array", + "choices": [ + { "type": "integer", "minimum": 0 }, + { "type": "array", "items": { "type": "integer", "minimum": 0 } } + ] + }, + { + "name": "shareName", + "type": "string", + "description": "The name of a sharing method on macOS", + "optional": true + } + ] + }, + { + "name": "openSharingPreferences", + "type": "function", + "description": "Opens the preferences of sharing services on macOS.", + "async": true, + "parameters": [] + }, + + { + "name": "listAutoplayBlockedTabs", + "type": "function", + "description": "Returns an array of autoplay blocked tabs in a window.", + "async": true, + "parameters": [ + { + "type": "integer", + "name": "windowId", + "minimum": 0, + "optional": true + } + ], + "returns": { + "type": "array", + "items": { + "$ref": "tabs.Tab" + } + } + }, + { + "name": "isAutoplayBlockedTab", + "type": "function", + "description": "Returns the tab's autoplay blocked state.", + "async": true, + "parameters": [ + { + "type": "integer", + "name": "windowId", + "minimum": 0 + } + ], + "returns": { + "type": "boolean", + "name": "autoplayBlocked" + } + }, + { + "name": "unblockAutoplay", + "type": "function", + "description": "Resumes blocked autoplay of tabs.", + "async": true, + "parameters": [ + { + "name": "tabIds", + "description": "The tab or list of tabs to resume autoplay.", + "type": "array", + "choices": [ + { "type": "integer", "minimum": 0 }, + { "type": "array", "items": { "type": "integer", "minimum": 0 } } + ] + } + ] + }, + + { + "name": "isSelectionClipboardAvailable", + "type": "function", + "description": "Returns availability of the selection clipboard.", + "async": true, + "parameters": [ + ], + "returns": { + "type": "boolean", + "name": "selectionClipboardAvailable" + } + }, + { + "name": "getSelectionClipboardContents", + "type": "function", + "description": "Returns the contents of the selection clipboard as a string.", + "async": true, + "parameters": [ + ], + "returns": { + "type": "string", + "name": "selectionClipboardContents" + } + } + ], + + "events": [ + { + "name": "onWindowVisibilityChanged", + "description": "Notified when the minimized state of a window is changed.", + "type": "function", + "parameters": [ + { + "type": "integer", + "name": "windowId", + "minimum": 0 + }, + { + "type": "string", + "name": "visibilityState" + } + ] + }, + { + "name": "onMenuCommand", + "description": "Notified when a menu command is invoked.", + "type": "function", + "parameters": [ + { + "name": "eventInfo", + "type": "object", + "description": "An object indicating command event.", + "properties": { + "itemId": { + "type": "string", + "description": "The id of the event target." + }, + "detail": { + "type": "integer", + "description": "Corresponding to MouseEvent.prototype.detail." + }, + "button": { + "type": "integer", + "description": "Corresponding to MouseEvent.prototype.button." + }, + "altKey": { + "type": "boolean", + "description": "Corresponding to MouseEvent.prototype.altKey." + }, + "ctrlKey": { + "type": "boolean", + "description": "Corresponding to MouseEvent.prototype.ctrlKey." + }, + "metaKey": { + "type": "boolean", + "description": "Corresponding to MouseEvent.prototype.metaKey." + }, + "shiftKey": { + "type": "boolean", + "description": "Corresponding to MouseEvent.prototype.shiftKey." + } + } + } + ] + }, + { + "name": "onSidebarShown", + "description": "Notified when the sidebar is shown.", + "type": "function", + "parameters": [ + { + "type": "integer", + "name": "windowId" + } + ] + }, + { + "name": "onSidebarHidden", + "description": "Notified when the sidebar is hidden.", + "type": "function", + "parameters": [ + { + "type": "integer", + "name": "windowId" + } + ] + }, + { + "name": "onAutoplayBlocked", + "description": "Notified when a tab's autoplay is blocked.", + "type": "function", + "parameters": [ + { + "$ref": "tabs.Tab" + } + ] + }, + { + "name": "onAutoplayUnblocked", + "description": "Notified when a tab's autoplay is unblocked.", + "type": "function", + "parameters": [ + { + "$ref": "tabs.Tab" + } + ] + } + ] + } +] \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/extlib/Configs.js b/waterfox/browser/components/sidebar/extlib/Configs.js new file mode 100644 index 000000000000..754cd03eefe4 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/Configs.js @@ -0,0 +1,630 @@ +/* + license: The MIT License, Copyright (c) 2016-2023 YUKI "Piro" Hiroshi + original: + http://github.com/piroor/webextensions-lib-configs +*/ + +'use strict'; + +/* +There are multiple level values: + +(higher priority) + * [default] locked managed values (given via GPO or policies.json) + * [default] locked default values (given to the constructor) + * [user] locked user values (given via API) + => [default] it should fallback to the default value if there is no user value + * [user] user values (local storage) + * [default] non-locked managed values (given via GPO or policies.json) + * [default] overridden default values (given via API) + * [default] built-in default values (given to the constructor) +(lower priority) + +Only values different from [default] are stored and synchronized. +*/ + +const OBSERVABLE_AREA = new Set([ + 'internal', // TST internal + 'local', + 'sync', + 'managed', +]); + +// eslint-disable-next-line no-unused-vars +class Configs { + constructor( + defaults, + { logging, logger, localKeys, syncKeys, sync } = { syncKeys: [], logger: null } + ) { + this._defaultValues = { + ...this._clone(defaults), + __ConfigsMigration__userValeusSameToDefaultAreCleared: false, + }; + this._lockedDefaultKeys = new Set(); + + this._managedValues = {}; + this._lockedManagedKeys = new Set(); + + this._userValues = {}; + this._lockedUserKeys = new Set(); + + this._fetchedValues = {}; + + this.$default = {}; + this.$all = {}; + + for (const key of Object.keys(this._defaultValues)) { + Object.defineProperty(this.$default, key, { + get: () => this._getDefaultValue(key), + set: (value) => this._setDefaultValue(key, value), + enumerable: true, + }); + const description = { + get: () => this._getValue(key), + set: (value) => this._setValue(key, value), + enumerable: true, + }; + Object.defineProperty(this, key, description); + Object.defineProperty(this.$all, key, description); + } + + for (const [key, locked] of Object.entries(defaults)) { + if (!key.endsWith(':locked')) + continue; + if (locked) + this._lockedDefaultKeys.add(key.replace(/:locked$/, '')); + delete defaults[key]; + } + + this.$logging = logging || false; + this.$logs = []; + this.$logger = logger; + this.sync = sync === undefined ? true : !!sync; + this._updating = new Map(); + this._observers = new Set(); + this._changedObservers = new Set(); + this._localLoadedObservers = new Set(); + this._syncKeys = [ + ...(localKeys ? + Object.keys(defaults).filter(x => !localKeys.includes(x)) : + (syncKeys || [])), + '__ConfigsMigration__userValeusSameToDefaultAreCleared', + ]; + this.$loaded = this._load(); + + this.$preReceivedChanges = []; + this.$listeningChanges = false; + browser.storage.onChanged.addListener(this._onChanged.bind(this)); + + this.$preReceivedMessages = []; + this.$listeningMessages = false; + browser.runtime.onMessage.addListener(this._onMessage.bind(this)); + } + + $reset(key, { broadcast } = {}) { + if (!key) { + for (const key of Object.keys(this._defaultValues)) { + this.$reset(key); + } + return; + } + + if (!this._defaultValues.hasOwnProperty(key)) + throw new Error(`failed to reset unknown key: ${key}`); + + this._setValue(key, this._getDefaultValue(key), true, { broadcast }); + } + + $cleanUp({ broadcast } = {}) { + for (const [key, defaultValue] of Object.entries(this.$default)) { + if (!this._userValues.hasOwnProperty(key)) + continue; + const value = JSON.stringify(this._getNonDefaultValue(key)); + if (value == JSON.stringify(defaultValue) || + (this._managedValues.hasOwnProperty(key) && + value == JSON.stringify(this._managedValues[key]))) + this.$reset(key, { broadcast }); + } + } + + _getDefaultValue(key) { + if (this._managedValues.hasOwnProperty(key)) + return this._managedValues[key]; + return this._defaultValues[key]; + } + + _setDefaultValue(key, value, { broadcast } = {}) { + if (!key) + throw new Error(`missing key for default value ${value}`); + + if (!this._defaultValues.hasOwnProperty(key)) + throw new Error(`failed to set default value for unknown key: ${key}`); + + const currentValue = this[key]; + const currentDefaultValue = this._getDefaultValue(key); + + this._defaultValues[key] = this._clone(value); + const defaultValue = this._getDefaultValue(key); + if (JSON.stringify(defaultValue) == JSON.stringify(this._getNonDefaultValue[key])) + this.$reset(key, { broadcast }); + + const newDefaultValue = this._getDefaultValue(key); + if (currentValue == currentDefaultValue && + currentValue != newDefaultValue && + this[key] == newDefaultValue) { + const observers = [...this._observers, ...this._changedObservers]; + this.$notifyToObservers(key, value, observers, 'onChangeConfig'); + } + + if (broadcast === false) + return; + + try { + browser.runtime.sendMessage({ + type: 'Configs:updateDefaultValue', + key: key, + value: defaultValue, + }).catch(_error => {}); + } + catch(_error) { + } + } + + _getNonDefaultValue(key) { + if (this._userValues.hasOwnProperty(key)) + return this._userValues[key]; + if (this._managedValues.hasOwnProperty(key) && + !this._lockedManagedKeys.has(key)) + return this._managedValues[key]; + return undefined; + } + + $addLocalLoadedObserver(observer) { + if (!this._localLoadedObservers.has(observer)) + this._localLoadedObservers.add(observer); + } + $removeLocalLoadedObserver(observer) { + this._localLoadedObservers.delete(observer); + } + + $addChangedObserver(observer) { + if (!this._changedObservers.has(observer)) + this._changedObservers.add(observer); + } + $removeChangedObserver(observer) { + this._changedObservers.delete(observer); + } + + $addObserver(observer) { + // for backward compatibility + if (typeof observer == 'function') + this.$addChangedObserver(observer); + else if (!this._observers.has(observer)) + this._observers.add(observer); + } + $removeObserver(observer) { + // for backward compatibility + if (typeof observer == 'function') + this.$removeChangedObserver(observer); + else + this._observers.delete(observer); + } + + _log(message, ...args) { + message = `Configs[${location.href}] ${message}`; + this.$logs = this.$logs.slice(-1000); + + if (!this.$logging) + return; + + if (typeof this.$logger === 'function') + this.$logger(message, ...args); + else + console.log(message, ...args); + } + + _load() { + return this.$_promisedLoad || + (this.$_promisedLoad = this._tryLoad()); + } + + async _tryLoad() { + this._log('load'); + try { + this._log(`load: try load from storage on ${location.href}`); + const [localValues, managedValues, lockedKeys] = await Promise.all([ + (async () => { + try { + const localValues = await browser.storage.local.get(null); // keys must be "null" to get only stored values + this._log('load: successfully loaded local storage'); + const observers = [...this._observers, ...this._localLoadedObservers]; + for (const [key, value] of Object.entries(localValues)) { + this.$notifyToObservers(key, value, observers, 'onLocalLoaded'); + } + return localValues; + } + catch(e) { + this._log('load: failed to load local storage: ', String(e)); + } + return {}; + })(), + (async () => { + if (!browser.storage.managed) { + this._log('load: skip managed storage'); + return null; + } + return new Promise(async (resolve, _reject) => { + const loadManagedStorage = () => { + let resolved = false; + return new Promise((resolve, reject) => { + browser.storage.managed.get().then(managedValues => { + if (resolved) + return; + resolved = true; + this._log('load: successfully loaded managed storage'); + resolve(managedValues || null); + }).catch(error => { + if (resolved) + return; + resolved = true; + this._log('load: failed to load managed storage: ', String(error)); + reject(error); + }); + + // storage.managed.get() fails on options page in Thunderbird. + // The problem should be fixed by Thunderbird side. + setTimeout(() => { + if (resolved) + return; + resolved = true; + this._log('load: failed to load managed storage: timeout'); + reject(new Error('timeout')); + }, 250); + }); + }; + + for (let i = 0, maxi = 10; i < maxi; i++) { + try { + const result = await loadManagedStorage(); + // On old versions Firefox and Thunderbird, a value with + // REG_MULTI_SZ type is always delivered as a simple string, + // thus we need to parse it by self. + for (const [key, value] of Object.entries(result)) { + const defaultValue = this._defaultValues[key]; + if (typeof value != 'string') + continue; + + const trimmed = value.trim(); + if (Array.isArray(defaultValue)) { + result[key] = (trimmed.startsWith('[') && trimmed.endsWith(']')) ? + JSON.parse(value) : + trimmed.includes('\n') ? + trimmed.split('\n') : + trimmed.split(','); + } + else if (defaultValue && + typeof defaultValue == 'object' && + trimmed.startsWith('{') && + trimmed.endsWith('}')) { + result[key] = JSON.parse(trimmed); + } + } + resolve(result); + return; + } + catch(error) { + if (error.message != 'timeout') { + console.log('managed storage is not provided'); + resolve(null); + return; + } + console.log('failed to load managed storage ', error); + } + await new Promise(resolve => setTimeout(resolve, 250)); + } + console.log('failed to load managed storage with 10 times retly'); + resolve(null); + }); + })(), + (async () => { + try { + const lockedKeys = await browser.runtime.sendMessage({ + type: 'Configs:getLockedKeys' + }); + this._log('load: successfully synchronized locked state'); + return lockedKeys || []; + } + catch(e) { + this._log('load: failed to synchronize locked state: ', String(e)); + } + return []; + })() + ]); + this._log(`load: loaded:`, { localValues, managedValues, lockedKeys }); + + lockedKeys.push(...this._lockedDefaultKeys); + + if (managedValues) { + for (const [key, value] of Object.entries(managedValues)) { + if (key.endsWith(':locked')) + continue; + const locked = managedValues[`${key}:locked`] !== false; + this._managedValues[key] = value; + if (locked) + this._lockedManagedKeys.add(key); + } + } + + this._userValues = this._clone({ ...(localValues || {}) }); + this._log('load: values are applied'); + + for (const key of new Set(lockedKeys)) { + this._updateLocked(key, true); + } + this._log('load: locked state is applied'); + this.$listeningChanges = true; + if (this.sync && + (this._syncKeys || + this._syncKeys.length > 0)) { + try { + browser.storage.sync.get(this._syncKeys).then(syncedValues => { + this._log('load: successfully loaded sync storage'); + if (!syncedValues) + return; + for (const key of Object.keys(syncedValues)) { + this[key] = syncedValues[key]; + } + }); + } + catch(e) { + this._log('load: failed to read sync storage: ', String(e)); + return null; + } + } + this.$listeningMessages = true; + + if (!this.__ConfigsMigration__userValeusSameToDefaultAreCleared) { + this.$cleanUp(); + this.__ConfigsMigration__userValeusSameToDefaultAreCleared = true; + } + + this.$_promisedLoad = this.$_promisedLoad.then(() => { + if (this.$preReceivedChanges.length > 0) { + const changes = [...this.$preReceivedChanges]; + this.$preReceivedChanges = []; + for (const change of changes) { + this._onChanged(change, 'internal'); + } + } + if (this.$preReceivedMessages.length > 0) { + const messages = [...this.$preReceivedMessages]; + this.$preReceivedMessages = []; + for (const message of messages) { + this._onMessage(message.message, message.sender); + } + } + }); + + return this.$all; + } + catch(e) { + this._log('load: fatal error: ', e, e.stack); + throw e; + } + } + + _getValue(key) { + if (this._lockedManagedKeys.has(key)) + return this._managedValues[key]; + if (this._lockedDefaultKeys.has(key)) + return this._defaultValues[key]; + if (this._lockedUserKeys.has(key)) + return this._userValues[key] || this._getDefaultValue(key); + if (this._userValues.hasOwnProperty(key)) + return this._userValues[key]; + if (this._managedValues.hasOwnProperty(key)) + return this._managedValues[key]; + if (this._defaultValues.hasOwnProperty(key)) + return this._defaultValues[key]; + throw new Error(`invalid access: unknown key ${key}`); + } + + _setValue(key, value, force = false, { broadcast } = {}) { + const newValue = this._clone(value); + + if (this._lockedDefaultKeys.has(key) || + this._lockedManagedKeys.has(key) || + this._lockedUserKeys.has(key)) { + this._log(`warning: ${key} is locked and not updated`); + return newValue; + } + + const stringified = JSON.stringify(value); + if (stringified == JSON.stringify(this._userValues[key]) && !force) { + this._log(`skip: ${key} is not changed`); + return newValue; + } + + const oldValue = this._getValue(key); + + const shouldReset = stringified == JSON.stringify(this._getDefaultValue(key)); + this._log(`set: ${key} = ${value}${shouldReset ? ' (reset to default)' : ''}`); + if (shouldReset) + delete this._userValues[key]; + else + this._userValues[key] = newValue; + + if (broadcast === false) + return newValue; + + const update = {}; + update[key] = newValue; + try { + const updatingValues = this._updating.get(key) || []; + updatingValues.push(newValue); + this._updating.set(key, updatingValues); + const updated = shouldReset ? + browser.storage.local.remove([key]).then(() => { + this._log('local: successfully removed ', key); + }) : + browser.storage.local.set(update).then(() => { + this._log('local: successfully saved ', update); + }); + updated + .then(() => { + setTimeout(() => { + const updatingValues = this._updating.get(key); + if (!updatingValues || + !updatingValues.includes(newValue)) + return; + // failsafe: on Thunderbird updates sometimes won't be notified to the page itself. + const changes = {}; + changes[key] = { + oldValue, + newValue, + }; + this._onChanged(changes, 'internal'); + }, 250); + }); + } + catch(e) { + this._log('save: failed', e); + } + try { + if (this.sync && this._syncKeys.includes(key)) { + if (shouldReset) + browser.storage.sync.remove([key]).then(() => { + this._log('sync: successfully removed', update); + }); + else + browser.storage.sync.set(update).then(() => { + this._log('sync: successfully synced', update); + }); + } + } + catch(e) { + this._log('sync: failed', e); + } + return newValue; + } + + $lock(key) { + this._log('locking: ' + key); + this._updateLocked(key, true); + } + + $unlock(key) { + this._log('unlocking: ' + key); + this._updateLocked(key, false); + } + + $isLocked(key) { + return this._lockedUserKeys.has(key); + } + + _updateLocked(key, locked, { broadcast } = {}) { + if (locked) + this._lockedUserKeys.add(key); + else + this._lockedUserKeys.delete(key); + + if (browser.runtime && + broadcast !== false) { + try { + browser.runtime.sendMessage({ + type: 'Configs:updateLocked', + key: key, + locked: this._lockedUserKeys.has(key), + }).catch(_error => {}); + } + catch(_error) { + } + } + } + + _onMessage(message, sender) { + if (!message || + typeof message.type != 'string') + return; + + if (!this.$listeningMessages) { + this.$preReceivedMessages.push({ message, sender }); + return; + } + + this._log(`onMessage: ${message.type}`, message, sender); + switch (message.type) { + case 'Configs:getLockedKeys': + return Promise.resolve(Array.from(this._lockedUserKeys)); + + case 'Configs:updateLocked': + this._updateLocked(message.key, message.locked, { broadcast: false }); + break; + + case 'Configs:updateDefaultValue': + this._setDefaultValue(message.key, message.value, { broadcast: false }); + break; + } + } + + _onChanged(changes, areaName) { + if (!OBSERVABLE_AREA.has(areaName)) + return; + + if (!this.$listeningChanges) { + this.$preReceivedChanges.push(changes); + return; + } + + this._log('_onChanged ', areaName, changes); + const observers = [...this._observers, ...this._changedObservers]; + for (const [key, change] of Object.entries(changes)) { + // storage.local.onChanged is sometimes notified with delay, and it + // unexpctedly reverts stored user value after it is changed multiple + // times in short time range, and it may produce "ghost value" problem, like: + // 1. setting to "true" (updates the stored value to "true" immediately) + // 2. setting to "false" (updates the stored value to "false" immediately) + // 3. "true" is notified (updates the stored value to "true" with delay) + // 4. getting the value - it gots "true" instead of "false"! + // To avoid such problems, we need to skip applying notified new value + // if the notification is from a local change. + const updatingValues = this._updating.get(key); + if (updatingValues && + updatingValues[0] == change.newValue) { + updatingValues.shift(); + } + else { + if ('newValue' in change) + this._userValues[key] = this._clone(change.newValue); + else + delete this._userValues[key]; + } + + if (!updatingValues || updatingValues.length == 0) + this._updating.delete(key); + else + this._updating.set(key, updatingValues); + + const value = this._getNonDefaultValue(key); + + if (JSON.stringify(value) == JSON.stringify(this._getDefaultValue(key))) + return; + + this.$notifyToObservers(key, value, observers, 'onChangeConfig'); + } + } + + $notifyToObservers(key, value, observers, observerMethod) { + for (const observer of observers) { + if (typeof observer === 'function') + observer(key, value); + else if (observer && typeof observer[observerMethod] === 'function') + observer[observerMethod](key, value); + } + } + + _clone(value) { + return JSON.parse(JSON.stringify(value)); + } +}; +export default Configs; diff --git a/waterfox/browser/components/sidebar/extlib/EventListenerManager.js b/waterfox/browser/components/sidebar/extlib/EventListenerManager.js new file mode 100644 index 000000000000..4a9f1c3306ec --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/EventListenerManager.js @@ -0,0 +1,117 @@ +/* + license: The MIT License, Copyright (c) 2018 YUKI "Piro" Hiroshi + original: + https://github.com/piroor/webextensions-lib-event-listener-manager +*/ + +'use strict'; + +const TIMEOUT = 2000; + +export default class EventListenerManager { + constructor() { + this._listeners = new Set(); + this._stacksOnListenerAdded = new WeakMap(); + this._sourceOfListeners = new WeakMap(); + } + + addListener(listener) { + const listeners = this._listeners; + if (!listeners.has(listener)) { + listeners.add(listener); + if (EventListenerManager.debug) { + const stack = new Error().stack; + this._stacksOnListenerAdded.set(listener, stack); + this._sourceOfListeners.set(listener, stack.split('\n')[1]); + } + } + } + + removeListener(listener) { + this._listeners.delete(listener); + this._stacksOnListenerAdded.delete(listener); + this._sourceOfListeners.delete(listener); + } + + removeAllListeners() { + this._listeners.clear(); + this._stacksOnListenerAdded.clear(); + this._sourceOfListeners.clear(); + } + + hasListener() { + return this._listeners.size > 0; + } + + dispatch(...args) { + const results = this.dispatchWithDetails(...args); + if (results instanceof Promise) + return results.then(this.normalizeResults); + else + return this.normalizeResults(results); + } + + // We hope to process results synchronously if possibly, + // so this method must not be "async". + dispatchWithDetails(...args) { + const results = Array(this._listeners.size); + let index = 0; + + for (let listener of this._listeners) { + const startAt = EventListenerManager.debug && Date.now(); + const timer = EventListenerManager.debug && setTimeout(() => { + const listenerAddedStack = this._stacksOnListenerAdded.get(listener); + console.log(`listener does not respond in ${TIMEOUT}ms.\n----------------------\n${listenerAddedStack || 'non debug mode or already removed listener:\n'+listener.toString()}\n----------------------\n${new Error().stack}\n----------------------\nargs:`, args); + }, TIMEOUT); + + try { + const result = listener(...args); + if (result instanceof Promise) + results[index++] = result + .catch(e => { + console.log(e); + }) + .then(result => { + if (timer) + clearTimeout(timer); + return { + value: result, + elapsed: EventListenerManager.debug && (Date.now() - startAt), + async: true, + listener: EventListenerManager.debug && this._sourceOfListeners.get(listener) + }; + }); + if (timer) + clearTimeout(timer); + results[index++] = { + value: result, + elapsed: EventListenerManager.debug && (Date.now() - startAt), + async: false, + listener: EventListenerManager.debug && this._sourceOfListeners.get(listener) + }; + } + catch(e) { + console.log(e); + if (timer) + clearTimeout(timer); + } + } + + if (results.some(result => result instanceof Promise)) + return Promise.all(results); + else + return results; + } + + normalizeResults(results) { + if (results.length == 1) + return results[0].value; + for (const result of results) { + if (result.value === false) + return false; + } + return true; + } +} + +EventListenerManager.debug = false; diff --git a/waterfox/browser/components/sidebar/extlib/MenuUI.js b/waterfox/browser/components/sidebar/extlib/MenuUI.js new file mode 100644 index 000000000000..fe04fe77b1be --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/MenuUI.js @@ -0,0 +1,1144 @@ +/* + license: The MIT License, Copyright (c) 2018-2025 YUKI "Piro" Hiroshi + original: + https://github.com/piroor/webextensions-lib-menu-ui +*/ +'use strict'; + +{ + class MenuUI { + static $wait(timeout) { + return new Promise((resolve, _reject) => { + setTimeout(resolve, timeout); + }); + } + + // XPath Utilities + static $hasClass(className) { + return `contains(concat(" ", normalize-space(@class), " "), " ${className} ")`; + }; + + static $evaluateXPath(expression, context, type) { + if (!type) + type = XPathResult.ORDERED_NODE_SNAPSHOT_TYPE; + try { + return (context.ownerDocument || context).evaluate( + expression, + (context || document), + null, + type, + null + ); + } + catch(_e) { + return { + singleNodeValue: null, + snapshotLength: 0, + snapshotItem: function() { + return null + } + }; + } + } + + static $getArrayFromXPathResult(result) { + const max = result.snapshotLength; + const array = new Array(max); + if (!max) + return array; + + for (let i = 0; i < max; i++) { + array[i] = result.snapshotItem(i); + } + return array; + } + + RTL_LANGUAGES = new Set([ + 'ar', + 'he', + 'fa', + 'ur', + 'ps', + 'sd', + 'ckb', + 'prs', + 'rhg', + ]); + + get isRTL() { + const lang = ( + navigator.language || + navigator.userLanguage || + //(new Intl.DateTimeFormat()).resolvedOptions().locale || + '' + ).split('-')[0]; + return this.RTL_LANGUAGES.has(lang); + } + + constructor(params = {}) { + this.$lastHoverItem = null; + this.$lastFocusedItem = null; + this.$mouseDownFired = false; + + this.root = params.root; + this.onCommand = params.onCommand || (() => {}); + this.onShown = params.onShown || (() => {}); + this.onHidden = params.onHidden || (() => {}); + this.animationDuration = params.animationDuration || 150; + this.subMenuOpenDelay = params.subMenuOpenDelay || 300; + this.subMenuCloseDelay = params.subMenuCloseDelay || 300; + this.appearance = params.appearance || 'menu'; + this.incrementalSearch = params.incrementalSearch || false; + this.incrementalSearchTimeout = params.incrementalSearchTimeout || 1000; + + this.$onBlur = this.$onBlur.bind(this); + this.$onMouseOver = this.$onMouseOver.bind(this); + this.$onMouseDown = this.$onMouseDown.bind(this); + this.$onMouseUp = this.$onMouseUp.bind(this); + this.$onClick = this.$onClick.bind(this); + this.$onKeyDown = this.$onKeyDown.bind(this); + this.$onKeyUp = this.$onKeyUp.bind(this); + this.$onTransitionEnd = this.$onTransitionEnd.bind(this); + this.$onContextMenu = this.$onContextMenu.bind(this); + + this.root.classList.toggle('rtl', this.isRTL); + + if (!this.root.id) + this.root.id = `MenuUI-root-${this.$uniqueKey}-${parseInt(Math.random() * Math.pow(2, 16))}`; + + this.root.classList.add(this.$commonClass); + this.root.classList.add('menu-ui'); + this.root.classList.add(this.appearance); + this.root.setAttribute('role', 'menu'); + + this.$screen = document.createElement('div'); + this.$screen.classList.add(this.$commonClass); + this.$screen.classList.add('menu-ui-blocking-screen'); + this.root.parentNode.insertBefore(this.$screen, this.root.nextSibling); + + this.$marker = document.createElement('span'); + this.$marker.classList.add(this.$commonClass); + this.$marker.classList.add('menu-ui-marker'); + this.$marker.classList.add(this.appearance); + this.root.parentNode.insertBefore(this.$marker, this.root.nextSibling); + + this.$lastKeyInputAt = -1; + this.$incrementalSearchString = ''; + } + + get opened() { + return this.root.classList.contains('open'); + } + + $updateAccessKey(item) { + const ACCESS_KEY_MATCHER = /(&([^\s]))/i; + + const title = item.getAttribute('title'); + if (title) + item.setAttribute('title', title.replace(ACCESS_KEY_MATCHER, '$2')); + + const label = MenuUI.$evaluateXPath('child::text()', item, XPathResult.STRING_TYPE).stringValue; + item.dataset.lowerCasedText = label.toLowerCase(); + + const matchedKey = label.match(ACCESS_KEY_MATCHER); + if (matchedKey) { + const textNode = MenuUI.$evaluateXPath( + `child::node()[contains(self::text(), "${matchedKey[1]}")]`, + item, + XPathResult.FIRST_ORDERED_NODE_TYPE + ).singleNodeValue; + if (textNode) { + const range = document.createRange(); + const startPosition = textNode.nodeValue.indexOf(matchedKey[1]); + range.setStart(textNode, startPosition); + range.setEnd(textNode, startPosition + 2); + range.deleteContents(); + const accessKeyNode = document.createElement('span'); + accessKeyNode.classList.add('accesskey'); + accessKeyNode.textContent = matchedKey[2]; + range.insertNode(accessKeyNode); + range.detach(); + } + item.dataset.accessKey = matchedKey[2].toLowerCase(); + } + else if (/^([^\s])/i.test(item.textContent)) + item.dataset.subAccessKey = RegExp.$1.toLowerCase(); + else + item.dataset.accessKey = item.dataset.subAccessKey = null; + } + + async open(options = {}) { + if (this.closeTimeout) { + clearTimeout(this.closeTimeout); + delete this.closeTimeout; + this.$onClosed(); + } + this.canceller = options.canceller; + this.$mouseDownAfterOpen = false; + this.$lastFocusedItem = null; + this.$lastHoverItem = null; + this.anchor = options.anchor; + this.$updateItems(this.root); + this.root.classList.add('open'); + this.$screen.classList.add('open'); + this.$marker.classList.remove('top'); + this.$marker.classList.remove('bottom'); + if (this.anchor) { + this.anchor.classList.add('open'); + this.$marker.style.transition = `opacity ${this.animationDuration}ms ease-out`; + this.$marker.classList.add('open'); + } + this.$updatePositions(this.root, options); + this.onShown(); + return new Promise(async (resolve, _reject) => { + await MenuUI.$wait(0); + if (this.$tryCancelOpen()) { + this.close().then(resolve); + return; + } + await MenuUI.$wait(this.animationDuration); + if (this.$tryCancelOpen()) { + this.close().then(resolve); + return; + } + this.root.parentNode.addEventListener('mouseover', this.$onMouseOver); + this.root.addEventListener('transitionend', this.$onTransitionEnd); + window.addEventListener('contextmenu', this.$onContextMenu, { capture: true }); + window.addEventListener('mousedown', this.$onMouseDown, { capture: true }); + window.addEventListener('mouseup', this.$onMouseUp, { capture: true }); + window.addEventListener('click', this.$onClick, { capture: true }); + window.addEventListener('keydown', this.$onKeyDown, { capture: true }); + window.addEventListener('keyup', this.$onKeyUp, { capture: true }); + window.addEventListener('blur', this.$onBlur, { capture: true }); + resolve(); + }); + } + + $tryCancelOpen() { + if (!(typeof this.canceller == 'function')) + return false; + try { + return this.canceller(); + } + catch(_e) { + } + return false; + } + + updateMenuItem(item) { + this.$updateItems(item); + const submenu = item.querySelector('ul'); + if (submenu) + this.$updatePositions(submenu); + } + + $updateItems(parent) { + parent.setAttribute('role', 'menu'); + for (const item of parent.querySelectorAll('li:not(.separator)')) { + item.setAttribute('tabindex', -1); + item.classList.remove('open'); + + if (item.classList.contains('checkbox')) + item.setAttribute('role', 'menuitemcheckbox'); + else if (item.classList.contains('radio')) + item.setAttribute('role', 'menuitemradio'); + else if (item.classList.contains('separator')) + item.setAttribute('role', 'separator'); + else + item.setAttribute('role', 'menuitem'); + + if (item.matches('.checked, .radio')) { + if (item.classList.contains('checked')) + item.setAttribute('aria-checked', 'true'); + else + item.setAttribute('aria-checked', 'false'); + } + else { + item.removeAttribute('aria-checked'); + } + + this.$updateAccessKey(item); + const icon = item.querySelector('span.icon') || document.createElement('span'); + if (!icon.parentNode) { + icon.classList.add('icon'); + item.insertBefore(icon, item.firstChild); + } + if (item.dataset.icon) { + if (item.dataset.iconColor) { + item.style.backgroundImage = ''; + icon.style.backgroundColor = item.dataset.iconColor; + icon.style.mask = `url(${JSON.stringify(item.dataset.icon)}) no-repeat center / 100%`; + } + else { + item.style.backgroundImage = `url(${JSON.stringify(item.dataset.icon)})`; + icon.style.backgroundColor = + icon.style.mask = ''; + } + } + else { + item.style.backgroundImage = + icon.style.backgroundColor = + icon.style.mask = ''; + } + if (item.querySelector('ul')) + item.classList.add('has-submenu'); + else + item.classList.remove('has-submenu'); + } + } + + $updatePositions(parent, options = {}) { + const menus = [parent].concat(Array.from(parent.querySelectorAll('ul'))); + for (const menu of menus) { + if (this.animationDuration) + menu.style.transition = `opacity ${this.animationDuration}ms ease-out`; + else + menu.style.transition = ''; + this.$updatePosition(menu, options); + } + } + + $updatePosition(menu, options = {}) { + let left = options.left; + let top = options.top; + const containerRect = this.$containerRect; + const menuRect = menu.getBoundingClientRect(); + + if (options.anchor && + (left === undefined || top === undefined) && + menu == this.root) { + const anchorRect = options.anchor.getBoundingClientRect(); + if (containerRect.bottom - anchorRect.bottom >= menuRect.height) { + top = anchorRect.bottom; + this.$marker.classList.add('top'); + this.$marker.classList.remove('bottom'); + this.$marker.style.top = `calc(${top}px - 0.4em)`; + } + else if (anchorRect.top - containerRect.top >= menuRect.height) { + top = Math.max(0, anchorRect.top - menuRect.height); + this.$marker.classList.add('bottom'); + this.$marker.classList.remove('top'); + this.$marker.style.top = `calc(${top}px + ${menuRect.height}px - 0.6em)`; + } + else { + top = Math.max(0, containerRect.top - menuRect.height); + this.$marker.classList.remove('bottom'); + this.$marker.classList.remove('top'); + this.$marker.style.top = `calc(${top}px + ${menuRect.height}px - 0.6em)`; + } + + const canPlaceAtRight = containerRect.right - anchorRect.left >= menuRect.width; + const canPlaceAtLeft = anchorRect.left - containerRect.left >= menuRect.width; + + if (canPlaceAtRight || canPlaceAtLeft) { + if (this.isRTL) { + if (canPlaceAtLeft) { + left = Math.max(0, anchorRect.right - menuRect.width); + this.$marker.style.left = `calc(${left}px + ${menuRect.width}px - 1.5em)`; + } + else { + left = anchorRect.left; + this.$marker.style.left = `calc(${left}px + 0.5em)`; + } + } + else { + if (canPlaceAtRight) { + left = anchorRect.left; + this.$marker.style.left = `calc(${left}px + 0.5em)`; + } + else { + left = Math.max(0, anchorRect.right - menuRect.width); + this.$marker.style.left = `calc(${left}px + ${menuRect.width}px - 1.5em)`; + } + } + + } + else { + left = Math.max(0, containerRect.left - menuRect.width); + this.$marker.style.left = `calc(${left}px + ${menuRect.width}px - 1.5em)`; + } + } + + let parentRect; + if (menu.parentNode.localName == 'li') { + parentRect = menu.parentNode.getBoundingClientRect(); + left = this.isRTL ? parentRect.left - menuRect.width : parentRect.right; + top = parentRect.top; + } + + if (left === undefined) + left = Math.max(0, (containerRect.width - menuRect.width) / 2); + else if (this.isRTL) + left -= menuRect.width; + + if (top === undefined) + top = Math.max(0, (containerRect.height - menuRect.height) / 2); + + if (!options.anchor && menu == this.root) { + // reposition to avoid the menu is opened below the cursor + if (containerRect.bottom - top < menuRect.height) { + top = top - menuRect.height; + } + if (containerRect.right - left < menuRect.width) { + left = left - menuRect.width; + } + } + + const minMargin = 3; + const overwrap = 4; + const firstTryLeft = Math.max(minMargin, Math.min(left - overwrap, containerRect.width - menuRect.width - minMargin)); + if (parentRect && + firstTryLeft < parentRect.right - overwrap && + containerRect.left < parentRect.left - menuRect.width + overwrap) { + left = parentRect.left - menuRect.width + overwrap; + } + else { + left = firstTryLeft; + } + menu.style.left = `${left}px`; + + top = Math.max(minMargin, Math.min(top, containerRect.height - menuRect.height - minMargin)); + if (menu == this.root && this.$marker.classList.contains('top')) + menu.style.top = `calc(${top}px + 0.5em)`; + else if (menu == this.root && this.$marker.classList.contains('bottom')) + menu.style.top = `calc(${top}px - 0.5em)`; + else + menu.style.top = `${top}px`; + } + + async close() { + if (!this.opened) + return; + this.$tryCancelOpen(); + this.root.classList.remove('open'); + this.$screen.classList.remove('open'); + if (this.anchor) { + this.anchor.classList.remove('open'); + this.$marker.classList.remove('open'); + } + this.$mouseDownAfterOpen = false; + this.$lastFocusedItem = null; + this.$lastHoverItem = null; + this.$mouseDownFired = false; + this.anchor = null; + this.canceller = null; + return new Promise((resolve, _reject) => { + this.closeTimeout = setTimeout(() => { + delete this.closeTimeout; + this.$onClosed(); + resolve(); + }, this.animationDuration); + }); + } + $onClosed() { + const menus = [this.root].concat(Array.from(this.root.querySelectorAll('ul'))); + for (const menu of menus) { + this.$updatePosition(menu, { left: 0, right: 0 }); + } + this.root.parentNode.removeEventListener('mouseover', this.$onMouseOver); + this.root.removeEventListener('transitionend', this.$onTransitionEnd); + window.removeEventListener('contextmenu', this.$onContextMenu, { capture: true }); + window.removeEventListener('mousedown', this.$onMouseDown, { capture: true }); + window.removeEventListener('mouseup', this.$onMouseUp, { capture: true }); + window.removeEventListener('click', this.$onClick, { capture: true }); + window.removeEventListener('keydown', this.$onKeyDown, { capture: true }); + window.removeEventListener('keyup', this.$onKeyUp, { capture: true }); + window.removeEventListener('blur', this.$onBlur, { capture: true }); + this.onHidden(); + } + + get $containerRect() { + const x = 0; + const y = 0; + const width = window.innerWidth; + const height = window.innerHeight; + return { + x, y, width, height, + left: x, + top: y, + right: width, + bottom: height + }; + } + + focusTo(item) { + this.$lastFocusedItem = this.$lastHoverItem = item; + this.$lastFocusedItem.focus(); + this.$lastFocusedItem.scrollIntoView({ block: 'nearest' }); + } + + $onBlur(event) { + if (event.target == document) + this.close(); + } + + $onMouseOver(event) { + let item = this.$getEffectiveItem(event.target); + if (this.delayedOpen && this.delayedOpen.item != item) { + clearTimeout(this.delayedOpen.timer); + this.delayedOpen = null; + } + if (item && item.delayedClose) { + clearTimeout(item.delayedClose); + item.delayedClose = null; + } + if (item && item.classList.contains('separator')) { + this.$lastHoverItem = item; + item = null; + } + if (!item) { + if (this.$lastFocusedItem) { + if (this.$lastFocusedItem.parentNode != this.root) { + this.focusTo(this.$lastFocusedItem.parentNode.parentNode); + } + else { + this.$lastFocusedItem.blur(); + this.$lastFocusedItem = null; + } + } + this.$setHover(null); + return; + } + + this.$setHover(item); + this.$closeOtherSubmenus(item); + this.focusTo(item); + + this.delayedOpen = { + item: item, + timer: setTimeout(() => { + this.delayedOpen = null; + this.$openSubmenuFor(item); + }, this.subMenuOpenDelay) + }; + } + + $setHover(item) { + for (const item of this.root.querySelectorAll('li.hover')) { + if (item != item) + item.classList.remove('hover'); + } + if (item) + item.classList.add('hover'); + } + + $openSubmenuFor(item) { + const items = MenuUI.$evaluateXPath( + `ancestor-or-self::li[${MenuUI.$hasClass('has-submenu')}][not(${MenuUI.$hasClass('disabled')})]`, + item + ); + for (const item of MenuUI.$getArrayFromXPathResult(items)) { + item.classList.add('open'); + } + } + + $closeOtherSubmenus(item) { + const items = MenuUI.$evaluateXPath( + `preceding-sibling::li[${MenuUI.$hasClass('has-submenu')}] | + following-sibling::li[${MenuUI.$hasClass('has-submenu')}] | + preceding-sibling::li/descendant::li[${MenuUI.$hasClass('has-submenu')}] | + following-sibling::li/descendant::li[${MenuUI.$hasClass('has-submenu')}]`, + item + ); + for (const item of MenuUI.$getArrayFromXPathResult(items)) { + item.delayedClose = setTimeout(() => { + item.classList.remove('open'); + }, this.subMenuCloseDelay); + } + } + + $onMouseDown(event) { + event.stopImmediatePropagation(); + event.stopPropagation(); + event.preventDefault(); + this.$mouseDownAfterOpen = true; + this.$mouseDownFired = true; + } + + $getEffectiveItem(node) { + const target = node.closest('li'); + let untransparentTarget = target && target.closest('ul'); + while (untransparentTarget) { + if (parseFloat(window.getComputedStyle(untransparentTarget, null).opacity) < 1) + return null; + untransparentTarget = untransparentTarget.parentNode.closest('ul'); + if (untransparentTarget == document) + break; + } + return target; + } + + $onMouseUp(event) { + if (!this.$mouseDownAfterOpen && + event.target.closest(`#${this.root.id}`)) + this.$onClick(event); + } + + async $onClick(event) { + event.stopImmediatePropagation(); + event.stopPropagation(); + event.preventDefault(); + + const target = this.$getEffectiveItem(event.target); + if (!target || + target.classList.contains('separator') || + target.classList.contains('has-submenu') || + target.classList.contains('disabled')) { + if (this.$mouseDownFired && // ignore "click" event triggered by a mousedown fired before the menu is opened (like long-press) + !event.target.closest(`#${this.root.id}`)) + return this.close(); + return; + } + + this.onCommand(target, event); + } + + $getNextFocusedItemByAccesskey(key) { + for (const attribute of ['access-key', 'sub-access-key']) { + const current = this.$lastHoverItem || this.$lastFocusedItem || this.root.firstChild; + const condition = `@data-${attribute}="${key.toLowerCase()}"`; + const item = this.$getNextItem(current, condition); + if (item) + return item; + } + return null; + } + + $incrementalSearchNextFocusedItem(key) { + this.$incrementalSearchString += key.toLowerCase(); + const current = this.$lastHoverItem || this.$lastFocusedItem || this.root.firstChild; + const condition = `starts-with(@data-lower-cased-text, "${this.$incrementalSearchString.replace(/"/g, '\\"')}")`; + if (this.$isItemMatches(current, condition)) + return current; + const item = this.$getNextItem(current, condition); + return item; + } + + $shouldSearchIncremental() { + if (!this.incrementalSearch) + return false; + + const last = this.$lastKeyInputAt; + const now = this.$lastKeyInputAt = Date.now(); + if (last < 0) + return true; // start + + if (now - last > this.incrementalSearchTimeout) + this.$incrementalSearchString = ''; + + return true; // continue + } + + $onKeyDown(event) { + switch (event.key) { + case 'ArrowUp': + event.stopPropagation(); + event.preventDefault(); + this.$advanceFocus(-1); + break; + + case 'ArrowDown': + event.stopPropagation(); + event.preventDefault(); + this.$advanceFocus(1); + break; + + case 'ArrowRight': + event.stopPropagation(); + event.preventDefault(); + if (this.isRTL) + this.$digOut(); + else + this.$digIn(); + break; + + case 'ArrowLeft': + event.stopPropagation(); + event.preventDefault(); + if (this.isRTL) + this.$digIn(); + else + this.$digOut(); + break; + + case 'Home': + event.stopPropagation(); + event.preventDefault(); + this.$advanceFocus(1, ( + this.$lastHoverItem && this.$lastHoverItem.parentNode || + this.$lastFocusedItem && this.$lastFocusedItem.parentNode || + this.root + ).lastChild); + break; + + case 'End': + event.stopPropagation(); + event.preventDefault(); + this.$advanceFocus(-1, ( + this.$lastHoverItem && this.$lastHoverItem.parentNode || + this.$lastFocusedItem && this.$lastFocusedItem.parentNode || + this.root + ).firstChild); + break; + + case 'Enter': { + event.stopPropagation(); + event.preventDefault(); + const targetItem = this.$lastHoverItem || this.$lastFocusedItem; + if (targetItem) { + if (targetItem.classList.contains('disabled')) + this.close(); + else if (!targetItem.classList.contains('separator')) + this.onCommand(targetItem, event); + } + }; break; + + case 'Escape': { + event.stopPropagation(); + event.preventDefault(); + const targetItem = this.$lastHoverItem || this.$lastFocusedItem; + if (!targetItem || + targetItem.parentNode == this.root) + this.close(); + else + this.$digOut(); + }; break; + + case 'BackSpace': { + if (this.$shouldSearchIncremental()) { + event.stopPropagation(); + event.preventDefault(); + this.$incrementalSearchString = this.$incrementalSearchString.slice(0, this.$incrementalSearchString.length - 1); + const item = this.$incrementalSearchNextFocusedItem(''); + if (item) { + this.focusTo(item); + this.$setHover(null); + } + } + }; break; + + default: + if (event.key.length == 1) { + if (this.$shouldSearchIncremental()) { + event.stopPropagation(); + event.preventDefault(); + const item = this.$incrementalSearchNextFocusedItem(event.key); + if (item) { + this.focusTo(item); + this.$setHover(null); + } + return; + } + + const item = this.$getNextFocusedItemByAccesskey(event.key); + if (item) { + this.focusTo(item); + this.$setHover(null); + if (this.$getNextFocusedItemByAccesskey(event.key) == item && + !item.classList.contains('disabled')) { + if (item.querySelector('ul')) + this.$digIn(); + else + this.onCommand(item, event); + } + } + } + return; + } + } + + $onKeyUp(event) { + switch (event.key) { + case 'ArrowUp': + case 'ArrowDown': + case 'ArrowRight': + case 'ArrowLeft': + case 'Home': + case 'End': + case 'Enter': + case 'Escape': + case 'Bakcspace': + event.stopPropagation(); + event.preventDefault(); + return; + + default: + if (event.key.length == 1 && + (this.$shouldSearchIncremental() || + this.$getNextFocusedItemByAccesskey(event.key))) { + event.stopPropagation(); + event.preventDefault(); + } + return; + } + } + + $getPreviousItem(base, condition = '') { + const extrcondition = condition ? `[${condition}]` : '' ; + const item = ( + MenuUI.$evaluateXPath( + `preceding-sibling::li[not(${MenuUI.$hasClass('separator')})]${extrcondition}[1]`, + base, + XPathResult.FIRST_ORDERED_NODE_TYPE + ).singleNodeValue || + MenuUI.$evaluateXPath( + `following-sibling::li[not(${MenuUI.$hasClass('separator')})]${extrcondition}[last()]`, + base, + XPathResult.FIRST_ORDERED_NODE_TYPE + ).singleNodeValue || + MenuUI.$evaluateXPath( + `self::li[not(${MenuUI.$hasClass('separator')})]${extrcondition}`, + base, + XPathResult.FIRST_ORDERED_NODE_TYPE + ).singleNodeValue + ); + if (window.getComputedStyle(item, null).display == 'none') + return this.$getPreviousItem(item, condition); + return item; + } + + $getNextItem(base, condition = '') { + const extrcondition = condition ? `[${condition}]` : '' ; + const item = ( + MenuUI.$evaluateXPath( + `following-sibling::li[not(${MenuUI.$hasClass('separator')})]${extrcondition}[1]`, + base, + XPathResult.FIRST_ORDERED_NODE_TYPE + ).singleNodeValue || + MenuUI.$evaluateXPath( + `preceding-sibling::li[not(${MenuUI.$hasClass('separator')})]${extrcondition}[last()]`, + base, + XPathResult.FIRST_ORDERED_NODE_TYPE + ).singleNodeValue || + MenuUI.$evaluateXPath( + `self::li[not(${MenuUI.$hasClass('separator')})]${extrcondition}`, + base, + XPathResult.FIRST_ORDERED_NODE_TYPE + ).singleNodeValue + ); + if (item && window.getComputedStyle(item, null).display == 'none') + return this.$getNextItem(item, condition); + return item; + } + + $isItemMatches(base, condition = '') { + const extrcondition = condition ? `[${condition}]` : '' ; + return !!MenuUI.$evaluateXPath( + `self::li[not(${MenuUI.$hasClass('separator')})]${extrcondition}`, + base, + XPathResult.FIRST_ORDERED_NODE_TYPE + ).singleNodeValue; + } + + $advanceFocus(direction, lastFocused = null) { + lastFocused = lastFocused || this.$lastHoverItem || this.$lastFocusedItem; + if (!lastFocused) { + if (direction < 0) + this.$lastFocusedItem = lastFocused = this.root.firstChild; + else + this.$lastFocusedItem = lastFocused = this.root.lastChild; + } + this.focusTo(direction < 0 ? this.$getPreviousItem(lastFocused) : this.$getNextItem(lastFocused)); + this.$setHover(null); + } + + $digIn() { + if (!this.$lastFocusedItem) { + this.$advanceFocus(1, this.root.lastChild); + return; + } + const submenu = this.$lastFocusedItem.querySelector('ul'); + if (!submenu || this.$lastFocusedItem.classList.contains('disabled')) + return; + this.$closeOtherSubmenus(this.$lastFocusedItem); + this.$openSubmenuFor(this.$lastFocusedItem); + this.$advanceFocus(1, submenu.lastChild); + } + + $digOut() { + const targetItem = this.$lastHoverItem || this.$lastFocusedItem; + if (!targetItem || + targetItem.parentNode == this.root) + return; + this.$closeOtherSubmenus(targetItem); + this.$lastFocusedItem = targetItem.parentNode.parentNode; + this.$closeOtherSubmenus(this.$lastFocusedItem); + this.$lastFocusedItem.classList.remove('open'); + this.focusTo(targetItem.parentNode.parentNode); + this.$setHover(null); + } + + $onTransitionEnd(event) { + const hoverItems = this.root.querySelectorAll('li:hover'); + if (hoverItems.length == 0) + return; + const $lastHoverItem = hoverItems[hoverItems.length - 1]; + const item = this.$getEffectiveItem($lastHoverItem); + if (!item) + return; + if (item.parentNode != event.target) + return; + this.$setHover(item); + this.focusTo(item); + } + + $onContextMenu(event) { + event.stopImmediatePropagation(); + event.stopPropagation(); + event.preventDefault(); + } + + + static $installStyles() { + this.style = document.createElement('style'); + this.style.setAttribute('type', 'text/css'); + const common = `.${this.$commonClass}`; + this.style.textContent = ` + ${common}.menu-ui, + ${common}.menu-ui ul { + background-color: var(--menu-ui-background-color); + color: var(--menu-ui-text-color); + cursor: default; + margin: 0; + max-height: calc(100% - 6px); + max-width: calc(100% - 6px); + opacity: 0; + overflow: hidden; /* because scrollbars always trap mouse events even if it is invisible. See also: https://github.com/piroor/treestyletab/issues/2386 */ + padding: 0; + pointer-events: none; + position: fixed; + z-index: 999999; + } + ${common}.menu-ui.rtl { + direction: rtl; + } + + ${common}.menu-ui.open, + ${common}.menu-ui.open li.open > ul { + opacity: 1; + overflow: auto; + pointer-events: auto; + } + + ${common}.menu-ui li { + list-style: none; + margin: 0; + padding: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + ${common}.menu-ui li:not(.separator):focus, + ${common}.menu-ui li:not(.separator).open { + background-color: var(--menu-ui-background-color-active); + color: var(--menu-ui-text-color-active); + } + + ${common}.menu-ui li.radio.checked::before, + ${common}.menu-ui li.checkbox.checked::before { + content: "✔"; + position: absolute; + inset-inline-start: 0.5em; + } + + ${common}.menu-ui li.separator { + height: 0.5em; + visibility: hidden; + margin: 0; + padding: 0; + } + + ${common}.menu-ui li.has-submenu, + ${common}.menu-ui.menu li.has-submenu { + padding-inline-end: 1em; + } + ${common}.menu-ui li.has-submenu::after { + content: "❯"; + inset-inline-end: 0.25em; + position: absolute; + transform: scale(0.75); + } + + ${common}.menu-ui .accesskey { + text-decoration: underline; + } + + ${common}.menu-ui-blocking-screen { + display: none; + } + + ${common}.menu-ui-blocking-screen.open { + bottom: 0; + display: block; + left: 0; + position: fixed; + right: 0; + top: 0; + z-index: 899999; + } + + ${common}.menu-ui.menu li:not(.separator):focus, + ${common}.menu-ui.menu li:not(.separator).open { + outline: none; + } + + ${common}.menu-ui.panel li:not(.separator):focus ul li:not(:focus):not(.open), + ${common}.menu-ui.panel li:not(.separator).open ul li:not(:focus):not(.open) { + background-color: transparent; + color: var(--menu-ui-text-color); + } + + ${common}.menu-ui-marker { + opacity: 0; + pointer-events: none; + position: fixed; + } + + ${common}.menu-ui-marker.open { + border: 0.5em solid transparent; + content: ""; + height: 0; + left: 0; + opacity: 1; + top: 0; + width: 0; + z-index: 999999; + } + + ${common}.menu-ui-marker.top { + border-bottom: 0.5em solid var(--menu-ui-background-color); + } + ${common}.menu-ui-marker.bottom { + border-top: 0.5em solid var(--menu-ui-background-color); + } + + ${common}.menu-ui li.disabled { + opacity: 0.5; + } + + ${common}.menu-ui li[data-icon], + ${common}.menu-ui.menu li[data-icon], + ${common}.menu-ui.panel li[data-icon] { + --icon-size: 16px; + background-repeat: no-repeat; + background-size: var(--icon-size); + padding-inline-start: calc(var(--icon-size) + 0.7em); + } + ${common}.menu-ui:not(.rtl) li[data-icon], + ${common}.menu-ui:not(.rtl).menu li[data-icon], + ${common}.menu-ui:not(.rtl).panel li[data-icon] { + background-position: left center; + } + ${common}.menu-ui.rtl li[data-icon], + ${common}.menu-ui.rtl.menu li[data-icon], + ${common}.menu-ui.rtl.panel li[data-icon] { + background-position: right center; + } + + ${common}.menu-ui li.checkbox, + ${common}.menu-ui li.radio, + ${common}.menu-ui.menu li.checkbox, + ${common}.menu-ui.panel li.checkbox, + ${common}.menu-ui.menu li.radio, + ${common}.menu-ui.panel li.radio { + padding-inline-start: 1.7em; + } + + /* panel-like appearance */ + ${common}.panel { + --menu-ui-background-color: -moz-dialog; + --menu-ui-text-color: -moz-dialogtext; + --menu-ui-background-color-active: Highlight; + --menu-ui-text-color-active: HighlightText; + } + ${common}.panel li.disabled { + --menu-ui-background-color-active: InactiveCaptionText; + --menu-ui-text-color-active: InactiveCaption; + } + ${common}.menu-ui.panel, + ${common}.menu-ui.panel ul { + box-shadow: 0.1em 0.1em 0.8em rgba(0, 0, 0, 0.65); + padding: 0.25em 0; + } + + ${common}.menu-ui.panel li { + padding: 0.15em 1em 0.15em 0.7em; + } + + + /* Menu-like appearance */ + ${common}.menu { + --menu-ui-background-color: Menu; + --menu-ui-text-color: MenuText; + --menu-ui-background-color-active: Highlight; + --menu-ui-text-color-active: HighlightText; + } + ${common}.menu li.disabled { + --menu-ui-background-color-active: InactiveCaptionText; + --menu-ui-text-color-active: InactiveCaption; + } + ${common}.menu-ui.menu, + ${common}.menu-ui.menu ul { + border: 1px outset Menu; + box-shadow: 0.1em 0.1em 0.5em rgba(0, 0, 0, 0.65); + font: -moz-pull-down-menu; + } + + ${common}.menu-ui.menu li { + padding: 0.15em 0.5em 0.15em 0.7em; + } + + ${common}.menu-ui.menu li.separator { + border: 1px inset Menu; + height: 0; + margin: 0 0.5em; + max-height: 0; + opacity: 0.5; + padding: 0; + visibility: visible; + } + + ${common}.menu-ui.menu:not(.rtl) li[data-icon]:not([data-icon-color]), + ${common}.menu-ui.panel:not(.rtl) li[data-icon]:not([data-icon-color]) { + background-position: 0.5em center; + } + ${common}.menu-ui.menu.rtl li[data-icon]:not([data-icon-color]), + ${common}.menu-ui.panel.rtl li[data-icon]:not([data-icon-color]) { + background-position: calc(100% - 0.5em) center; + } + + ${common}.menu-ui li:not([data-icon]) .icon, + ${common}.menu-ui li[data-icon]:not([data-icon-color]) .icon { + display: none; + } + + ${common}.menu-ui li[data-icon][data-icon-color] .icon { + display: inline-block; + height: var(--icon-size); + inset-inline-start: 0.5em; + max-height: var(--icon-size); + max-width: var(--icon-size); + position: absolute; + width: var(--icon-size); + } + `; + document.head.appendChild(this.style); + } + + static init() { + MenuUI.$uniqueKey = parseInt(Math.random() * Math.pow(2, 16)); + MenuUI.$commonClass = `menu-ui-${MenuUI.$uniqueKey}`; + + MenuUI.prototype.$uniqueKey = MenuUI.$uniqueKey; + MenuUI.prototype.$commonClass = MenuUI.$commonClass; + + MenuUI.$installStyles(); + + window.MenuUI = MenuUI; + } + }; + + MenuUI.init(); +} +export default MenuUI; diff --git a/waterfox/browser/components/sidebar/extlib/Options.js b/waterfox/browser/components/sidebar/extlib/Options.js new file mode 100644 index 000000000000..dddd3a7b28bc --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/Options.js @@ -0,0 +1,420 @@ +/* + license: The MIT License, Copyright (c) 2016-2020 YUKI "Piro" Hiroshi + original: + http://github.com/piroor/webextensions-lib-options +*/ + +class Options { + constructor(configs, { steps, onImporting, onImported, onExporting, onExported } = {}) { + this.configs = configs; + this.steps = steps || {}; + this.$onImporting = onImporting; + this.$onImported = onImported; + this.$onExporting = onExporting; + this.$onExported = onExported; + this.uiNodes = new Map(); + this.throttleTimers = new Map(); + + this.onReady = this.onReady.bind(this); + this.onConfigChanged = this.onConfigChanged.bind(this) + document.addEventListener('DOMContentLoaded', this.onReady); + } + + findUIsForKey(key) { + key = this._sanitizeForSelector(key); + if (!key) + return []; + return document.querySelectorAll(`[name="${key}"], #${key}, [data-config-key="${key}"]`); + } + + detectUIType(node) { + if (!node) + return this.UI_MISSING; + + if (node.localName == 'textarea') + return this.UI_TYPE_TEXT_FIELD; + + if (node.localName == 'select') + return this.UI_TYPE_SELECTBOX; + + if (node.localName != 'input') + return this.UI_TYPE_UNKNOWN; + + switch (node.type) { + case 'text': + case 'password': + case 'number': + case 'color': + return this.UI_TYPE_TEXT_FIELD; + + case 'checkbox': + return this.UI_TYPE_CHECKBOX; + + case 'radio': + return this.UI_TYPE_RADIO; + + default: + return this.UI_TYPE_UNKNOWN; + } + } + + throttledUpdate(key, uiNode, value) { + if (this.throttleTimers.has(key)) + clearTimeout(this.throttleTimers.get(key)); + uiNode.dataset.configValueUpdating = true; + this.throttleTimers.set(key, setTimeout(() => { + this.throttleTimers.delete(key); + this.configs[key] = this.UIValueToConfigValue(key, value); + setTimeout(() => { + uiNode.dataset.configValueUpdating = false; + }, 50); + }, 250)); + } + + UIValueToConfigValue(key, value) { + switch (typeof this.configs.$default[key]) { + case 'string': + return String(value); + + case 'number': + return Number(value); + + case 'boolean': + if (typeof value == 'string') + return value != 'false'; + else + return Boolean(value); + + default: // object + if (typeof value == 'string') + return JSON.parse(value || 'null'); + else + return value; + } + } + + configValueToUIValue(value) { + if (typeof value == 'object') { + value = JSON.stringify(value); + if (value == 'null') + value = ''; + return value; + } + else + return value; + } + + applyLocked(node, key) { + const locked = this.configs.$isLocked(key); + node.disabled = locked; + const selector = node.id && `label[for="${this._sanitizeForSelector(node.id)}"]`; + const label = node.closest('label') || (node.id && node.ownerDocument.querySelector(selector)) || node; + if (label) + label.classList.toggle('locked', locked); + } + + bindToCheckbox(key, node) { + node.checked = this.configValueToUIValue(this.configs[key]); + node.addEventListener('change', () => { + this.throttledUpdate(key, node, node.checked); + }); + this.applyLocked(node, key); + this.addResetMethod(key, node); + const nodes = this.uiNodes.get(key) || []; + nodes.push(node); + this.uiNodes.set(key, nodes); + } + bindToRadio(key, node) { + const group = node.getAttribute('name'); + const radios = document.querySelectorAll(`input[name="${this._sanitizeForSelector(group)}"]`); + let activated = false; + for (const radio of radios) { + const nodes = this.uiNodes.get(key) || []; + if (nodes.includes(radio)) + continue; + this.applyLocked(radio, key); + nodes.push(radio); + this.uiNodes.set(key, nodes); + radio.addEventListener('change', () => { + if (!activated) + return; + const stringifiedValue = this.configs[key]; + if (stringifiedValue != radio.value) + this.throttledUpdate(key, radio, radio.value); + }); + } + const selector = `input[type="radio"][value=${JSON.stringify(String(this.configs[key]))}]`; + const chosens = (this.uiNodes.get(key) || []).filter(node => node.matches(selector)); + if (chosens && chosens.length > 0) + chosens.map(chosen => { chosen.checked = true; }); + setTimeout(() => { + activated = true; + }, 0); + } + bindToTextField(key, node) { + node.value = this.configValueToUIValue(this.configs[key]); + node.addEventListener('input', () => { + this.throttledUpdate(key, node, node.value); + }); + this.applyLocked(node, key); + this.addResetMethod(key, node); + const nodes = this.uiNodes.get(key) || []; + nodes.push(node); + this.uiNodes.set(key, nodes); + } + bindToSelectBox(key, node) { + node.value = this.configValueToUIValue(this.configs[key]); + node.addEventListener('change', () => { + this.throttledUpdate(key, node, node.value); + }); + this.applyLocked(node, key); + this.addResetMethod(key, node); + const nodes = this.uiNodes.get(key) || []; + nodes.push(node); + this.uiNodes.set(key, nodes); + } + addResetMethod(key, node) { + node.$reset = () => { + this.configs.$reset(key); + const value = this.configs.$default[key]; + if (this.detectUIType(node) == this.UI_TYPE_CHECKBOX) + node.checked = value; + else + node.value = value; + }; + } + + async onReady() { + document.removeEventListener('DOMContentLoaded', this.onReady); + + if (!this.configs || !this.configs.$loaded) + throw new Error('you must give configs!'); + + this.configs.$addObserver(this.onConfigChanged); + await this.configs.$loaded; + for (const key of Object.keys(this.configs.$default)) { + const nodes = this.findUIsForKey(key); + if (!nodes.length) + continue; + for (const node of nodes) { + switch (this.detectUIType(node)) { + case this.UI_TYPE_CHECKBOX: + this.bindToCheckbox(key, node); + break; + + case this.UI_TYPE_RADIO: + this.bindToRadio(key, node); + break; + + case this.UI_TYPE_TEXT_FIELD: + this.bindToTextField(key, node); + break; + + case this.UI_TYPE_SELECTBOX: + this.bindToSelectBox(key, node); + break; + + case this.UI_MISSING: + continue; + + default: + throw new Error(`unknown type UI element for ${key}`); + } + } + } + } + + onConfigChanged(key) { + const nodes = this.uiNodes.get(key); + if (!nodes || !nodes.length) + return; + + for (const node of nodes) { + if (node.dataset.configValueUpdating == 'true') + continue; + if (node.matches('input[type="radio"]')) { + node.checked = this.configs[key] == node.value; + } + else if (node.matches('input[type="checkbox"]')) { + node.checked = !!this.configs[key]; + } + else { + node.value = this.configValueToUIValue(this.configs[key]); + } + this.applyLocked(node, key); + } + } + + buildUIForAllConfigs(parent) { + parent = parent || document.body; + const range = document.createRange(); + range.selectNodeContents(parent); + range.collapse(false); + const rows = []; + for (const key of Object.keys(this.configs.$default).sort()) { + const value = this.configs.$default[key]; + const type = typeof value == 'number' ? 'number' : + typeof value == 'boolean' ? 'checkbox' : + 'text' ; + // To accept decimal values like "1.1", we need to set "step" with decmimal values. + // https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/number + const step = type != 'number' ? '' : `step="${this.sanitizeForHTMLText(key in this.steps ? this.steps[key] : String(1.75).replace(/[1-9]/g, '0').replace(/0$/, '1'))}"`; + const placeholder = type == 'checkbox' ? '' : `placeholder=${JSON.stringify(this.sanitizeForHTMLText(String(value)))}`; + rows.push(` + 0 ? 'style="border-top: 1px solid rgba(0, 0, 0, 0.2);"' : ''}> + + + + + + + + + + + `); + } + const fragment = range.createContextualFragment(` + + ${rows.join('')} +
        +
        + + + + + +
        + `); + range.insertNode(fragment); + range.detach(); + const table = document.getElementById('allconfigs-table'); + for (const input of table.querySelectorAll('input')) { + const key = input.id.replace(/^allconfigs-field-/, ''); + switch (this.detectUIType(input)) { + case this.UI_TYPE_CHECKBOX: + this.bindToCheckbox(key, input); + break; + + case this.UI_TYPE_TEXT_FIELD: + this.bindToTextField(key, input); + break; + } + const button = table.querySelector(`#allconfigs-reset-${this._sanitizeForSelector(key)}`); + button.addEventListener('click', () => { + input.$reset(); + }); + button.addEventListener('keyup', event => { + if (event.key == 'Enter' || event.key == ' ') + input.$reset(); + }); + } + const resetAllButton = document.getElementById('allconfigs-reset-all'); + resetAllButton.addEventListener('keydown', event => { + if (event.key == 'Enter' || event.key == ' ') + this.resetAll(); + }); + resetAllButton.addEventListener('click', event => { + if (event.button == 0) + this.resetAll(); + }); + const exportButton = document.getElementById('allconfigs-export'); + exportButton.addEventListener('keydown', event => { + if (event.key == 'Enter' || event.key == ' ') + this.exportToFile(); + }); + exportButton.addEventListener('click', event => { + if (event.button == 0) + this.exportToFile(); + }); + const importButton = document.getElementById('allconfigs-import'); + importButton.addEventListener('keydown', event => { + if (event.key == 'Enter' || event.key == ' ') + this.importFromFile(); + }); + importButton.addEventListener('click', event => { + if (event.button == 0) + this.importFromFile(); + }); + const fileField = document.getElementById('allconfigs-import-file'); + fileField.addEventListener('change', async _event => { + const text = await fileField.files.item(0).text(); + let values = JSON.parse(text); + if (typeof this.$onImporting == 'function') + values = await this.$onImporting(values); + for (const key of Object.keys(this.configs.$default)) { + const value = values[key] !== undefined ? values[key] : this.configs.$default[key]; + const changed = value != this.configs[key]; + this.configs[key] = value; + if (changed) + this.onConfigChanged(key); + } + if (typeof this.$onImported == 'function') + await this.$onImported(); + }); + } + sanitizeForHTMLText(text) { + return text.replace(/&/g, '&').replace(//g, '>'); + } + + resetAll() { + this.configs.$reset(); + } + + importFromFile() { + document.getElementById('allconfigs-import-file').click(); + } + + async exportToFile() { + let values = {}; + for (const key of Object.keys(this.configs.$default).sort()) { + const defaultValue = JSON.stringify(this.configs.$default[key]); + const currentValue = JSON.stringify(this.configs[key]); + if (defaultValue !== currentValue) { + values[key] = this.configs[key]; + } + } + if (typeof this.$onExporting == 'function') + values = await this.$onExporting(values); + // Pretty print the exported JSON, because some major addons + // including Stylus and uBlock do that. + const exported = JSON.stringify(values, null, 2); + const browserInfo = browser.runtime.getBrowserInfo && await browser.runtime.getBrowserInfo(); + switch (browserInfo && browserInfo.name) { + case 'Thunderbird': + window.open(`data:application/json,${encodeURIComponent(exported)}`); + break; + + default: + const link = document.getElementById('allconfigs-export-file'); + link.href = URL.createObjectURL(new Blob([exported], { type: 'application/json' })); + link.click(); + break; + } + if (typeof this.$onExported == 'function') + await this.$onExported(); + } + + _sanitizeForSelector(string) { + return string.replace(/[:\[\]()]/g, '\\$&'); + } +}; + +Options.prototype.UI_TYPE_UNKNOWN = 0; +Options.prototype.UI_TYPE_TEXT_FIELD = 1 << 0; +Options.prototype.UI_TYPE_CHECKBOX = 1 << 1; +Options.prototype.UI_TYPE_RADIO = 1 << 2; +Options.prototype.UI_TYPE_SELECTBOX = 1 << 3; + +export default Options; diff --git a/waterfox/browser/components/sidebar/extlib/RichConfirm.js b/waterfox/browser/components/sidebar/extlib/RichConfirm.js new file mode 100644 index 000000000000..bcc2b053ef14 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/RichConfirm.js @@ -0,0 +1,1621 @@ +/* + license: The MIT License, Copyright (c) 2018-2025 YUKI "Piro" Hiroshi + original: + https://github.com/piroor/webextensions-lib-rich-confirm +*/ +'use strict'; + +(function defineRichConfirm(uniqueKey) { + class RichConfirm { + constructor(params) { + this.params = params; + if (!this.params.buttons) + this.params.buttons = ['OK']; + this.onClick = this.onClick.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.onKeyUp = this.onKeyUp.bind(this); + this.onContextMenu = this.onContextMenu.bind(this); + this.onUnload = this.onUnload.bind(this); + } + get commonClass() { + return `rich-confirm-${this.uniqueKey}`; + } + get dialog() { + return this.ui.querySelector('.rich-confirm-dialog'); + } + get content() { + return this.ui.querySelector('.rich-confirm-content'); + } + get buttonsContainer() { + return this.ui.querySelector('.rich-confirm-buttons'); + } + get checkContainer() { + return this.ui.querySelector('.rich-confirm-check-label'); + } + get checkCheckbox() { + return this.ui.querySelector('.rich-confirm-check-checkbox'); + } + get checkMessage() { + return this.ui.querySelector('.rich-confirm-check-message'); + } + + get focusTargets() { + return Array.from(this.ui.querySelectorAll('input:not([type="hidden"]), textarea, select, button, *[tabindex]:not([tabindex^="-"])')).filter(node => node.offsetWidth > 0); + } + + RTL_LANGUAGES = new Set([ + 'ar', + 'he', + 'fa', + 'ur', + 'ps', + 'sd', + 'ckb', + 'prs', + 'rhg', + ]); + get isRTL() { + const lang = ( + navigator.language || + navigator.userLanguage || + //(new Intl.DateTimeFormat()).resolvedOptions().locale || + '' + ).split('-')[0]; + return this.RTL_LANGUAGES.has(lang); + } + + buildUI() { + if (this.ui) + return; + this.style = document.createElement('style'); + this.style.setAttribute('type', 'text/css'); + const common = `.${this.commonClass}`; + this.style.textContent = ` + /* color scheme */ + ${common}.rich-confirm, + :root${common} { + /* https://hg.mozilla.org/mozilla-central/raw-file/tip/toolkit/themes/shared/in-content/common.inc.css */ + --in-content-page-color: var(--grey-90); + --in-content-page-background: var(--grey-10); + --in-content-text-color: var(--in-content-page-color); + --in-content-deemphasized-text: var(--grey-60); + --in-content-selected-text: #fff; + --in-content-box-background: #fff; + --in-content-box-background-hover: var(--grey-20); + --in-content-box-background-active: var(--grey-30); + --in-content-box-border-color: var(--grey-90-a30); + --in-content-box-border-color-mixed: rgb(calc((249 * 0.7) + (12 * 0.3)), calc((249 * 0.7) + (12 * 0.3)), calc((250 * 0.7) + (13 * 0.3))); + --in-content-box-info-background: var(--grey-20); + --in-content-item-hover: rgba(69, 161, 255, 0.2); /* blue 40 a20 */ + --in-content-item-hover-mixed: rgb(calc((249 * 0.8) + (69 * 0.2)), calc((249 * 0.8) + (161 * 0.2)), calc((250 * 0.8) + (255 * 0.2))); + --in-content-item-selected: var(--blue-50); + --in-content-border-highlight: var(--blue-50); + --in-content-border-focus: var(--blue-50); + --in-content-border-hover: var(--grey-90-a50); + --in-content-border-hover-mixed: rgb(calc((249 * 0.5) + (12 * 0.5)), calc((249 * 0.5) + (12 * 0.5)), calc((250 * 0.5) + (13 * 0.5))); + --in-content-border-active: var(--blue-50); + --in-content-border-active-shadow: var(--blue-50-a30); + --in-content-border-invalid: var(--red-50); + --in-content-border-invalid-shadow: var(--red-50-a30); + --in-content-border-color: var(--grey-30); + --in-content-border-color-mixed: #d7d7db; + --in-content-category-outline-focus: 1px dotted var(--blue-50); + --in-content-category-text-selected: var(--blue-50); + --in-content-category-text-selected-active: var(--blue-60); + --in-content-category-background-hover: rgba(12,12,13,0.1); + --in-content-category-background-active: rgba(12,12,13,0.15); + --in-content-category-background-selected-hover: rgba(12,12,13,0.15); + --in-content-category-background-selected-active: rgba(12,12,13,0.2); + --in-content-tab-color: #424f5a; + --in-content-link-color: var(--blue-60); + --in-content-link-color-hover: var(--blue-70); + --in-content-link-color-active: var(--blue-80); + --in-content-link-color-visited: var(--blue-60); + --in-content-button-background: var(--grey-90-a10); + --in-content-button-background-mixed: rgb(calc((249 * 0.9) + (12 * 0.1)), calc((249 * 0.9) + (12 * 0.1)), calc((250 * 0.9) + (13 * 0.1))); + --in-content-button-background-hover: var(--grey-90-a20); + --in-content-button-background-hover-mixed: rgb(calc((249 * 0.8) + (12 * 0.2)), calc((249 * 0.8) + (12 * 0.2)), calc((250 * 0.8) + (13 * 0.2))); + --in-content-button-background-active: var(--grey-90-a30); + --in-content-button-background-active-mixed: rgb(calc((249 * 0.7) + (12 * 0.3)), calc((249 * 0.7) + (12 * 0.3)), calc((250 * 0.7) + (13 * 0.3))); + + --blue-40: #45a1ff; + --blue-40-a10: rgb(69, 161, 255, 0.1); + --blue-50: #0a84ff; + --blue-50-a30: rgba(10, 132, 255, 0.3); + --blue-60: #0060df; + --blue-70: #003eaa; + --blue-80: #002275; + --grey-10: #f9f9fa; + --grey-10-a015: rgba(249, 249, 250, 0.015); + --grey-10-a20: rgba(249, 249, 250, 0.2); + --grey-20: #ededf0; + --grey-30: #d7d7db; + --grey-40: #b1b1b3; + --grey-60: #4a4a4f; + --grey-90: #0c0c0d; + --grey-90-a10: rgba(12, 12, 13, 0.1); + --grey-90-a20: rgba(12, 12, 13, 0.2); + --grey-90-a30: rgba(12, 12, 13, 0.3); + --grey-90-a50: rgba(12, 12, 13, 0.5); + --grey-90-a60: rgba(12, 12, 13, 0.6); + --green-50: #30e60b; + --green-60: #12bc00; + --green-70: #058b00; + --green-80: #006504; + --green-90: #003706; + --orange-50: #ff9400; + --purple-70: #6200a4; + --red-50: #ff0039; + --red-50-a30: rgba(255, 0, 57, 0.3); + --red-60: #d70022; + --red-70: #a4000f; + --red-80: #5a0002; + --red-90: #3e0200; + --yellow-10: #ffff98; + --yellow-50: #ffe900; + --yellow-60: #d7b600; + --yellow-60-a30: rgba(215, 182, 0, 0.3); + --yellow-70: #a47f00; + --yellow-80: #715100; + --yellow-90: #3e2800; + + /* https://hg.mozilla.org/mozilla-central/raw-file/tip/browser/themes/addons/dark/manifest.json */ + --dark-frame: hsl(240, 5%, 5%); + --dark-icons: rgb(249, 249, 250, 0.7); + --dark-ntp-background: #2A2A2E; + --dark-ntp-text: rgb(249, 249, 250); + --dark-popup: #4a4a4f; + --dark-popup-border: #27272b; + --dark-popup-text: rgb(249, 249, 250); + --dark-sidebar: #38383D; + --dark-sidebar-text: rgb(249, 249, 250); + --dark-sidebar-border: rgba(255, 255, 255, 0.1); + --dark-tab-background-text: rgb(249, 249, 250); + --dark-tab-line: #0a84ff; + --dark-toolbar: hsl(240, 1%, 20%); + --dark-toolbar-bottom-separator: hsl(240, 5%, 5%); + --dark-toolbar-field: rgb(71, 71, 73); + --dark-toolbar-field-border: rgba(249, 249, 250, 0.2); + --dark-toolbar-field-separator: #5F6670; + --dark-toolbar-field-text: rgb(249, 249, 250); + + /* https://searchfox.org/mozilla-central/rev/35873cfc312a6285f54aa5e4ec2d4ab911157522/browser/themes/shared/tabs.inc.css#24 */ + --tab-loading-fill: #0A84FF; + + + --bg-color: var(--grey-10); + --text-color: var(--grey-90); + } + + ${common}.rich-confirm.rtl { + direction: rtl; + } + + ${common}.rich-confirm :link { + color: var(--in-content-link-color); + } + ${common}.rich-confirm :visited { + color: var(--in-content-link-color-visited); + } + + ${common}.rich-confirm :link:hover, + ${common}.rich-confirm :visited:hover { + color: var(--in-content-link-color-hover); + } + + ${common}.rich-confirm :link:active, + ${common}.rich-confirm :visited:active { + color: var(--in-content-link-color-active); + } + + @media (prefers-color-scheme: dark) { + ${common}.rich-confirm, + :root${common} { + /* https://hg.mozilla.org/mozilla-central/raw-file/tip/toolkit/themes/shared/in-content/common.inc.css */ + --in-content-page-background: #2A2A2E /* rgb(42, 42, 46) */; + --in-content-page-color: rgb(249, 249, 250); + --in-content-text-color: var(--in-content-page-color); + --in-content-deemphasized-text: var(--grey-40); + --in-content-box-background: #202023; + --in-content-box-background-hover: /* rgba(249,249,250,0.15) */ rgb(calc((42 * 0.85) + (249 * 0.15)), calc((42 * 0.85) + (249 * 0.15)), calc((46 * 0.85) + (250 * 0.15))); + --in-content-box-background-active: /*rgba(249,249,250,0.2) */ rgb(calc((42 * 0.8) + (249 * 0.2)), calc((42 * 0.8) + (249 * 0.2)), calc((46 * 0.8) + (250 * 0.2))); + --in-content-box-background-odd: rgba(249,249,250,0.05); + --in-content-box-info-background: rgba(249,249,250,0.15); + + --in-content-border-color: rgba(249,249,250,0.2); + --in-content-border-color-mixed: rgb(calc((42 * 0.8) + (249 * 0.2)), calc((42 * 0.8) + (249 * 0.2)), calc((46 * 0.8) + (250 * 0.2))); + --in-content-border-hover: rgba(249,249,250,0.3); + --in-content-border-hover-mixed: rgb(calc((42 * 0.7) + (249 * 0.3)), calc((42 * 0.7) + (249 * 0.3)), calc((46 * 0.7) + (250 * 0.3))); + --in-content-box-border-color: rgba(249,249,250,0.2); + --in-content-box-border-color-mixed: rgb(calc((42 * 0.8) + (249 * 0.2)), calc((42 * 0.8) + (249 * 0.2)), calc((46 * 0.8) + (250 * 0.2))); + + --in-content-button-background: rgba(249,249,250,0.1); + --in-content-button-background-mixed: rgb(calc((42 * 0.9) + (249 * 0.1)), calc((42 * 0.9) + (249 * 0.1)), calc((46 * 0.9) + (250 * 0.1))); + --in-content-button-background-hover: rgba(249,249,250,0.15); + --in-content-button-background-hover-mixed: rgb(calc((42 * 0.85) + (249 * 0.15)), calc((42 * 0.85) + (249 * 0.15)), calc((46 * 0.85) + (250 * 0.15))); + --in-content-button-background-active: rgba(249,249,250,0.2); + --in-content-button-background-active-mixed: rgb(calc((42 * 0.8) + (249 * 0.2)), calc((42 * 0.8) + (249 * 0.2)), calc((46 * 0.8) + (250 * 0.2))); + + --in-content-link-color: var(--blue-40); + --in-content-link-color-hover: var(--blue-50); + --in-content-link-color-active: var(--blue-60); + + --bg-color: var(--in-content-page-background); + --text-color: var(--in-content-text-color); + } + + ${common}.rich-confirm textarea, + ${common}.rich-confirm input { + background: var(--in-content-box-background); + border: thin solid var(--in-content-box-border-color-mixed); + color: var(--in-content-text-color); + } + ${common}.rich-confirm textarea:hover, + ${common}.rich-confirm input:hover { + border-color: var(--in-content-border-hover-mixed); + } + ${common}.rich-confirm textarea:focus, + ${common}.rich-confirm input:focus { + border-color: var(--in-content-border-focus); + box-shadow: 0 0 0 1px var(--in-content-border-active), + 0 0 0 4px var(--in-content-border-active-shadow); + } + + ${common}.rich-confirm fieldset, + ${common}.rich-confirm hr { + border: thin solid var(--in-content-box-border-color-mixed); + } + + ${common}.rich-confirm hr { + border-width: thin 0 0 0; + } + + ${common}.rich-confirm button, + ${common}.rich-confirm select { + background: var(--in-content-button-background-mixed); + border: 0 none transparent; + color: var(--in-content-text-color); + margin: 4px; + } + ${common}.rich-confirm button:hover, + ${common}.rich-confirm select:hover { + background: var(--in-content-button-background-hover-mixed); + } + ${common}.rich-confirm button:focus, + ${common}.rich-confirm select:focus { + background: var(--in-content-button-background-active-mixed); + box-shadow: 0 0 0 1px var(--in-content-border-active), + 0 0 0 4px var(--in-content-border-active-shadow); + } + ${common}.rich-confirm option { + background: var(--bg-color); + color: var(--text-color); + } + ${common}.rich-confirm option:active, + ${common}.rich-confirm option:focus { + background: var(--in-content-item-selected); + } + ${common}.rich-confirm option:hover { + background: var(--in-content-item-hover-mixed); + } + } + + ${common}.rich-confirm, + ${common}.rich-confirm-row { + align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + } + ${common}.rich-confirm:not(.simulation), + ${common}.rich-confirm-row:not(.simulation) { + align-items: stretch; + bottom: 0; + left: 0; + position: fixed; + right: 0; + top: 0; + } + + ${common}.rich-confirm { + left:0; + pointer-events: none; + z-index: 999997; + } + ${common}.rich-confirm.popup-window, + :root${common}.popup-window body { + background: var(--bg-color); + } + ${common}.rich-confirm:not(.popup-window) { + background: rgba(0, 0, 0, 0.45); + opacity: 0; + transition: opacity 250ms ease-out; + } + + ${common}.rich-confirm.show { + opacity: 1; + pointer-events: auto; + } + + ${common}.rich-confirm-row { + z-index: 999998; + } + + ${common}.rich-confirm-dialog { + color: var(--text-color); + font: message-box; + overflow: hidden; + padding: 1em; + z-index: 999999; + } + /* Don't apply "auto" immediately because it can produce needless scrollbar even if all contents are visible without scrolling. */ + ${common}.rich-confirm.shown .rich-confirm-dialog { + overflow: auto; + } + ${common}.rich-confirm.shown .rich-confirm-dialog.popup-window:not(.simulation) { + display: flex; + flex-direction: column; + flex-grow: 1; + } + ${common}.rich-confirm-dialog:not(.popup-window) { + background: var(--bg-color); + box-shadow: 0.1em 0.1em 0.5em rgba(0, 0, 0, 0.65); + margin: 0.5em; + max-height: 90%; + max-width: 90%; + } + + ${common}.rich-confirm-content { + white-space: pre-wrap; + } + ${common}.rich-confirm-content.popup-window:not(.simulation) { + display: flex; + flex-direction: column; + flex-grow: 1; + flex-shrink: 1; + } + + ${common}.rich-confirm-buttons { + align-items: center; + display: flex; + flex-direction: row; + margin: 0.5em 0 0; + } + + @media (min-width: 40em) { + ${common}.rich-confirm-buttons.type-dialog button, + ${common}.rich-confirm-buttons.type-common-dialog button { + white-space: nowrap; + } + + ${common}.rich-confirm-buttons.type-dialog { + justify-content: flex-end; + } + ${common}.rich-confirm-buttons.type-dialog button + button { + margin-inline-start: 1em; + } + + ${common}.rich-confirm-buttons.type-common-dialog { + justify-content: center; + } + ${common}.rich-confirm-buttons.type-common-dialog button + button { + margin-inline-start: 1em; + } + + ${common}.rich-confirm-buttons.type-dialog.mac, + ${common}.rich-confirm-buttons.type-dialog.linux, + ${common}.rich-confirm-buttons.type-common-dialog.mac, + ${common}.rich-confirm-buttons.type-common-dialog.linux { + justify-content: flex-start; + flex-direction: row-reverse; + } + ${common}.rich-confirm-buttons.type-dialog.mac button + button, + ${common}.rich-confirm-buttons.type-dialog.linux button + button, + ${common}.rich-confirm-buttons.type-common-dialog.mac button + button, + ${common}.rich-confirm-buttons.type-common-dialog.linux button + button { + margin-inline-end: 1em; + } + } + + /* popup type dialog always have horizontal buttons */ + ${common}.rich-confirm-buttons.popup-window.type-dialog button, + ${common}.rich-confirm-buttons.popup-window.type-common-dialog button { + white-space: nowrap; + } + ${common}.rich-confirm-buttons.popup-window.type-dialog { + justify-content: flex-end; + } + ${common}.rich-confirm-buttons.popup-window.type-dialog button + button { + margin-inline-start: 1em; + } + ${common}.rich-confirm-buttons.popup-window.type-common-dialog { + justify-content: center; + } + ${common}.rich-confirm-buttons.popup-window.type-common-dialog button + button { + margin-inline-start: 1em; + } + ${common}.rich-confirm-buttons.popup-window.type-dialog.mac, + ${common}.rich-confirm-buttons.popup-window.type-dialog.linux, + ${common}.rich-confirm-buttons.popup-window.type-common-dialog.mac, + ${common}.rich-confirm-buttons.popup-window.type-common-dialog.linux { + justify-content: flex-start; + flex-direction: row-reverse; + } + ${common}.rich-confirm-buttons.popup-window.type-dialog.mac button + button, + ${common}.rich-confirm-buttons.popup-window.type-dialog.linux button + button, + ${common}.rich-confirm-buttons.popup-window.type-common-dialog.mac button + button, + ${common}.rich-confirm-buttons.popup-window.type-common-dialog.linux button + button { + margin-inline-end: 1em; + } + + ${common}.rich-confirm-buttons:not(.type-dialog):not(.type-common-dialog) { + align-items: stretch; + flex-direction: column; + } + @media (max-width: 40em) { + ${common}.rich-confirm-buttons:not(.popup-window):not(.type-dialog).type-common-dialog { + align-items: stretch; + flex-direction: column; + } + } + + ${common}.rich-confirm button { + -moz-appearance: button; + font: message-box; + text-align: center; + } + + ${common}.rich-confirm-buttons:not(.type-dialog):not(.type-common-dialog) button { + display: block; + margin-bottom: 0.2em; + padding: 0.4em; + width: 100%; + } + @media (max-width: 40em) { + ${common}.rich-confirm-buttons:not(.popup-window):not(.type-dialog).type-common-dialog button { + display: block; + margin-bottom: 0.2em; + padding: 0.4em; + width: 100%; + } + } + + ${common}.rich-confirm-check-label { + display: flex; + flex-direction: row; + margin-top: 0.5em; + } + + ${common}.rich-confirm-check-label.hidden { + display: none; + } + + ${common}.rich-confirm .accesskey { + text-decoration: underline; + } + + + ${common}.rich-confirm.simulation { + max-height: 0 !important; + max-width: 0 !important; + overflow: hidden !important; + visibility: hidden !important; + } + ${common}.rich-confirm-row.simulation { + position: static !important; + } + ${common}.rich-confirm-dialog.simulation { + border: 1px solid; + } + `; + document.head.appendChild(this.style); + + const range = document.createRange(); + range.selectNodeContents(document.body); + range.collapse(false); + const commonClass = [ + this.commonClass, + this.params.popup ? 'popup-window' : '', + this.params.simulation ? 'simulation' : '', + this.params.type ? `type-${this.params.type}` : '', + /win/i.test(navigator.platform) ? 'windows' : + /mac/i.test(navigator.platform) ? 'mac' : + /linux/i.test(navigator.platform) ? 'linux' : + '', + this.isRTL ? 'rtl' : '', + ].join(' '); + const uniqueId = `created-at-${Date.now()}-${Math.floor(Math.random() * Math.pow(2, 24))}`; + const fragment = range.createContextualFragment(` +
        +
        + +
        +
        + `); + range.insertNode(fragment); + range.detach(); + this.ui = document.querySelector(`.rich-confirm.${this.commonClass}.${uniqueId}`); + } + + getNextFocusedNodeByAccesskey(key) { + for (const attribute of ['accesskey', 'data-access-key', 'data-sub-access-key']) { + const current = this.dialog.querySelector(':focus'); + const condition = `[${attribute}="${key.toLowerCase()}"]`; + const nextNode = this.getNextNode(current, condition); + if (nextNode) + return nextNode; + } + return null; + } + + getNextNode(base, condition = '') { + const matchedNodes = [...this.dialog.querySelectorAll(condition)]; + const currentIndex = matchedNodes.indexOf(base); + const nextNode = currentIndex == -1 || currentIndex == matchedNodes.index - 1 ? + matchedNodes[0] : matchedNodes[currentIndex + 1]; + if (nextNode && window.getComputedStyle(nextNode, null).display == 'none') + return this.getNextNode(nextNode, condition); + return nextNode; + } + + updateAccessKey(element) { + const ACCESS_KEY_MATCHER = /(&([^\s]))/i; + const label = element.textContent || (/^(button|submit|reset)$/i.test(element.type) && element.value) || ''; + const matchedKey = element.accessKey ? + label.match(new RegExp(`((${element.accessKey}))`, 'i')) : + label.match(ACCESS_KEY_MATCHER); + const accessKey = element.accessKey || (matchedKey && matchedKey[2]); + if (accessKey) { + element.accessKey = element.dataset.accessKey = accessKey.toLowerCase(); + if (matchedKey && + !/^(input|textarea)$/i.test(element.localName)) { + const textNode = this.evaluateXPath( + `child::node()[contains(self::text(), "${matchedKey[1]}")]`, + element, + XPathResult.FIRST_ORDERED_NODE_TYPE + ).singleNodeValue; + if (textNode) { + const range = document.createRange(); + const startPosition = textNode.nodeValue.indexOf(matchedKey[1]); + range.setStart(textNode, startPosition); + range.setEnd(textNode, startPosition + matchedKey[1].length); + range.deleteContents(); + const accessKeyNode = document.createElement('span'); + accessKeyNode.classList.add('accesskey'); + accessKeyNode.textContent = matchedKey[2]; + range.insertNode(accessKeyNode); + range.detach(); + } + } + } + else if (/^([^\s])/i.test(label)) + element.dataset.subAccessKey = RegExp.$1.toLowerCase(); + else + element.dataset.accessKey = element.dataset.subAccessKey = null; + } + + evaluateXPath(expression, context, type) { + if (!type) + type = XPathResult.ORDERED_NODE_SNAPSHOT_TYPE; + try { + return (context.ownerDocument || context).evaluate( + expression, + (context || document), + null, + type, + null + ); + } + catch(_e) { + return { + singleNodeValue: null, + snapshotLength: 0, + snapshotItem: function() { + return null + } + }; + } + } + + async show({ onShown, onDialogOpened } = {}) { + this.buildUI(); + await new Promise((resolve, _reject) => setTimeout(resolve, 0)); + + const range = document.createRange(); + + if (this.params.content) { + range.selectNodeContents(this.content); + range.collapse(false); + const fragment = range.createContextualFragment(this.params.content); + range.insertNode(fragment); + for (const element of this.content.querySelectorAll('[accesskey]')) { + this.updateAccessKey(element); + } + } + else if (this.params.message) { + this.content.textContent = this.params.message; + } + + if (this.params.checkMessage) { + this.checkMessage.textContent = this.params.checkMessage; + this.checkCheckbox.checked = !!this.params.checked; + this.checkContainer.classList.remove('hidden'); + } + else { + this.checkContainer.classList.add('hidden'); + } + + range.selectNodeContents(this.buttonsContainer); + range.deleteContents(); + const buttons = document.createDocumentFragment(); + for (const label of this.params.buttons) { + const button = document.createElement('button'); + button.textContent = label; + button.setAttribute('title', label); + buttons.appendChild(button); + this.updateAccessKey(button); + } + range.insertNode(buttons); + + this.ui.addEventListener('click', this.onClick); + window.addEventListener('keydown', this.onKeyDown, true); + window.addEventListener('keyup', this.onKeyUp, true); + window.addEventListener('contextmenu', this.onContextMenu, true); + window.addEventListener('pagehide', this.onUnload); + window.addEventListener('beforeunload', this.onUnload); + + const targets = this.focusTargets.filter(target => target != this.checkCheckbox); + targets[0].focus(); + + range.detach(); + + if (typeof this.params.onShown == 'function') { + try { + await this.params.onShown(this.content, this.params.inject || {}); + } + catch(error) { + console.error(error); + } + } + else if (Array.isArray(this.params.onShown)) { + for (const onShownPart of this.params.onShown) { + if (typeof onShownPart != 'function') + continue; + try { + await onShownPart(this.content, this.params.inject || {}); + } + catch(error) { + console.error(error); + } + } + } + + this.ui.querySelector('.rich-confirm-dialog').setAttribute('aria-modal', !!this.params.modal); + this.ui.classList.add('show'); + + if (typeof onShown == 'function') { + try { + await onShown(this.content, this.params.inject || {}); + } + catch(error) { + console.error(error); + } + } + else if (Array.isArray(onShown)) { + for (const onShownPart of onShown) { + if (typeof onShownPart != 'function') + continue; + try { + await onShownPart(this.content, this.params.inject || {}); + } + catch(error) { + console.error(error); + } + } + } + + if (typeof onDialogOpened == 'function') { + try { + await onDialogOpened({ + close: () => { + this.hide(); + }, + }); + } + catch(error) { + console.error(error); + } + } + + setTimeout(() => { + if (!this.ui || + !this.ui.classList) + return; + // Apply overflow:auto after all contents are correctly rendered. + this.ui.classList.add('shown'); + }, 10); + + return new Promise((resolve, reject) => { + this._resolve = resolve; + this._rejecte = reject; + }); + } + + async hide() { + this.ui.classList.remove('show'); + if (typeof this.params.onHidden == 'function') { + try { + this.params.onHidden(this.content, this.params.inject || {}); + } + catch(_error) { + } + } + this.ui.removeEventListener('click', this.onClick); + window.removeEventListener('keydown', this.onKeyDown, true); + window.removeEventListener('keyup', this.onKeyUp, true); + window.removeEventListener('contextmenu', this.onContextMenu, true); + window.removeEventListener('pagehide', this.onUnload); + window.removeEventListener('beforeunload', this.onUnload); + delete this._resolve; + delete this._rejecte; + const ui = this.ui; + const style = this.style; + delete this.ui; + delete this.style; + return new Promise((resolve, _reject) => { + window.setTimeout(() => { + // remove elements after animation is finished + ui.parentNode.removeChild(ui); + style.parentNode.removeChild(style); + }, 1000); + resolve(); + }); + } + + dismiss() { + const resolve = this._resolve; + const result = { + buttonIndex: -1, + checked: !!this.params.checkMessage && this.checkCheckbox.checked + }; + return this.hide().then(() => resolve(result)); + } + + onClick(event) { + let target = event.target; + if (target.nodeType == Node.TEXT_NODE) + target = target.parentNode; + + if (target.closest(`.rich-confirm-content.${this.commonClass}`) && + target.closest('input, textarea, select, button')) + return; + + if (event.button != 0) { + event.stopPropagation(); + event.preventDefault(); + return; + } + + const button = target.closest('button'); + if (button) { + event.stopPropagation(); + event.preventDefault(); + const buttonIndex = Array.from(this.buttonsContainer.childNodes).indexOf(button); + const values = {}; + for (const field of this.content.querySelectorAll('[id], [name]')) { + let value = null; + if (field.matches('input[type="checkbox"]')) { + value = field.checked; + } + else if (field.matches('input[type="radio"]')) { + if (field.checked) + value = field.value; + } + else if ('value' in field.dataset) { + value = field.dataset.value; + } + else { + value = field.value; + } + values[field.id || field.name] = value; + } + const resolve = this._resolve; + const result = { + buttonIndex, + values, + checked: !!this.params.checkMessage && this.checkCheckbox.checked + }; + this.hide().then(() => resolve(result)); + return; + } + + if (!this.params.popup && + !target.closest(`.rich-confirm-dialog.${this.commonClass}`)) { + event.stopPropagation(); + event.preventDefault(); + this.dismiss(); + } + } + + onKeyDown(event) { + let target = event.target; + if (target.nodeType == Node.TEXT_NODE) + target = target.parentNode; + const onContent = target.closest(`.rich-confirm-content.${this.commonClass}`); + + switch (event.key) { + case 'ArrowUp': + case 'PageUp': + if (onContent) + break; + event.stopPropagation(); + event.preventDefault(); + this.advanceFocus(-1); + break; + + case 'ArrowLeft': + if (onContent) + break; + event.stopPropagation(); + event.preventDefault(); + this.advanceFocus(this.isRTL ? 1 : -1); + break; + + case 'ArrowDown': + case 'PageDown': + if (onContent) + break; + event.stopPropagation(); + event.preventDefault(); + this.advanceFocus(1); + break; + + case 'ArrowRight': + if (onContent) + break; + event.stopPropagation(); + event.preventDefault(); + this.advanceFocus(this.isRTL ? -1 : 1); + break; + + case 'Home': + if (onContent) + break; + event.stopPropagation(); + event.preventDefault(); + this.focusTargets[0].focus(); + break; + + case 'End': + if (onContent) + break; + event.stopPropagation(); + event.preventDefault(); + const targets = this.focusTargets; + targets[targets.length-1].focus(); + break; + + case 'Tab': + event.stopPropagation(); + event.preventDefault(); + this.advanceFocus(event.shiftKey ? -1 : 1); + break; + + case 'Escape': + event.stopPropagation(); + event.preventDefault(); + this.dismiss(); + break; + + case 'Enter': + if (onContent && + !target.closest('textarea') && + !target.closest('[data-no-accept-by-enter="true"]')) { + event.stopPropagation(); + event.preventDefault(); + this.buttonsContainer.firstChild.click(); + } + break; + + default: { + const currentFocused = this.dialog.querySelector(':focus'); + const needAccelKey = ( + currentFocused && + (currentFocused.localName.toLowerCase() == 'textarea' || + (currentFocused.localName.toLowerCase() == 'input' && + /^(date|datetime|datetime-local|email|file|month|number|password|search|tel|text|time|url|week)$/i.test(currentFocused.type))) + ); + if ((!needAccelKey || event.altKey) && + !event.ctrlKey && + !event.shiftKey && + !event.metaKey && + event.key.length == 1) { + const node = this.getNextFocusedNodeByAccesskey(event.key); + if (node && typeof node.focus == 'function') { + node.focus(); + const nextNode = this.getNextFocusedNodeByAccesskey(event.key); + if ((!nextNode || nextNode == node) && + typeof node.click == 'function') + node.click(); + } + } + }; return; + } + } + + onKeyUp(event) { + switch (event.key) { + case 'ArrowUp': + case 'ArrowLeft': + case 'PageUp': + case 'ArrowDown': + case 'ArrowRight': + case 'PageDown': + case 'Home': + case 'End': + case 'Tab': + case 'Escape': + event.stopPropagation(); + event.preventDefault(); + break; + + default: + return; + } + } + + onContextMenu(event) { + let target = event.target; + if (target.nodeType == Node.TEXT_NODE) + target = target.parentNode; + const onContent = target.closest(`.rich-confirm-content.${this.commonClass}`); + if (!onContent || !target.closest('input, textarea')) { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + } + } + + onUnload() { + this.dismiss(); + } + + advanceFocus(direction) { + const focusedItem = this.ui.querySelector(':focus'); + console.log('focusedItem ', focusedItem); + const targets = this.focusTargets; + console.log('focusTargets ', targets); + const index = focusedItem ? targets.indexOf(focusedItem) : -1; + if (direction < 0) { // backward + const nextFocused = index < 0 ? targets[targets.length-1] : + targets[index == 0 ? targets.length-1 : index-1]; + nextFocused.focus(); + } + else { // forward + const nextFocused = index < 0 ? targets[0] : + targets[index == targets.length-1 ? 0 : index+1]; + nextFocused.focus(); + } + } + + static async show(params) { + const confirm = new this(params); + return confirm.show(); + } + + static async showInTab(tabId, params) { + if (!params) { + params = tabId; + tabId = (await browser.tabs.getCurrent()).id; + } + let onMessage; + const oneTimeKey = `popup-${this.uniqueKey}-${Date.now()}-${parseInt(Math.random() * Math.pow(2, 16))}`; + const promisedResult = new Promise((resolve, _reject) => { + onMessage = (message, _sender) => { + if (message?.oneTimeKey != oneTimeKey) + return; + + switch (message.type) { + case 'rich-confirm-dialog-shown': + if (typeof params.onReady == 'function') { + try { + params.onReady({ + width: message.dialogWidth, + height: message.dialogHeight + }); + } + catch(error) { + console.error(error); + } + } + break; + + case 'rich-confirm-dialog-complete': + resolve(message.result); + break; + } + }; + browser.runtime.onMessage.addListener(onMessage); + }); + try { + if (typeof browser.tabs.executeScript == 'function') // Manifest V2 + await browser.tabs.executeScript(tabId, { + code: ` + if (!window.RichConfirm) + (${defineRichConfirm.toString()})(${this.uniqueKey}); + `, + matchAboutBlank: true, + runAt: 'document_start' + }); + else // Manifest V3 + await browser.scripting.executeScript({ + target: { tabId }, + func: defineRichConfirm, + args: [this.uniqueKey], + }); + const transferableParams = { ...params }; + const injectTransferable = []; + const inject = params.inject || {}; + delete transferableParams.inject; + for (const key in params.inject) { + const value = inject[key]; + const transferable = ( + value && + typeof value == 'function' && + typeof value.toString == 'function' + ) ? value.toString() : JSON.stringify(value); + injectTransferable.push(`${JSON.stringify(key)} : ${transferable}`); + } + const stringifyOnShown = onShown => { + if (Array.isArray(onShown)) + return `[${onShown.map(stringifyOnShown).join(',')}]`; + return typeof onShown == 'function' ? + onShown.toString() + .replace(/^\s*(async\s+)?function/, '$1') + .replace(/^\s*(async\s+)?/, '$1 function ') + .replace(/^\s*(async\s+)?function ((?:\([^=\)]*\)|[^\(\)=]+)\s*=>\s*\{)/, '$1 $2') : + '() => {}'; + }; + const originalOnShown = stringifyOnShown(params.onShown); + delete transferableParams.onShown; + + const run = async function run(uniqueKey, oneTimeKey, originalOnShown, transferableParams, inject) { + delete window.RichConfirm.result; + const confirm = new RichConfirm({ + ...transferableParams, + inject: inject || {}, + async onShown(content, inject) { + if (!Array.isArray(originalOnShown)) + originalOnShown = [originalOnShown]; + for (const originalOnShownPart of originalOnShown) { + try { + if (typeof originalOnShownPart == 'function') + await originalOnShownPart(content, inject); + } + catch(error) { + console.error(error); + } + } + } + }); + const result = await confirm.show({ + onShown(content, _injected) { + const dialog = content.parentNode; + const rect = dialog.getBoundingClientRect(); + const style = window.getComputedStyle(dialog, null); + // End padding is not included in the scrillable size, + // so we manually add them. + const inlineEndPadding = dialog.scrollLeftMax > 0 && parseFloat(style.getPropertyValue('padding-inline-end')) || 0; + const bottomPadding = dialog.scrollTopMax > 0 && parseFloat(style.getPropertyValue('padding-bottom')) || 0; + browser.runtime.sendMessage({ + type: 'rich-confirm-dialog-shown', + uniqueKey, + oneTimeKey, + dialogWidth: rect.width + dialog.scrollLeftMax + inlineEndPadding, + dialogHeight: rect.height + dialog.scrollTopMax + bottomPadding + }); + }, + }); + browser.runtime.sendMessage({ + type: 'rich-confirm-dialog-complete', + uniqueKey, + oneTimeKey, + result + }); + }; + if (typeof browser.tabs.executeScript == 'function') // Manifest V2 + browser.tabs.executeScript(tabId, { + code: ` + (${run.toString()})( + ${this.uniqueKey}, + ${JSON.stringify(oneTimeKey)}, + (${originalOnShown.toString()}), + ${JSON.stringify(transferableParams)}, + {${injectTransferable.join(',')}} + ); + `, + matchAboutBlank: true, + runAt: 'document_start' + }); + else // Manifest V3 + browser.scripting.executeScript({ + target: { tabId }, + func: run, + args: [this.uniqueKey, oneTimeKey, originalOnShown, transferableParams, inject], + }); + // Don't return the promise directly here, instead await it + // because the "finally" block must be processed after + // the promise is resolved. + const result = await promisedResult; + return result; + } + catch(error) { + console.error(error, error.stack); + return { + buttonIndex: -1 + }; + } + finally { + if (browser.runtime.onMessage.hasListener(onMessage)) + browser.runtime.onMessage.removeListener(onMessage); + } + } + + static async showInPopup(ownerWinId, params) { + let ownerWin; + if (!params) { + params = ownerWinId; + ownerWin = await browser.windows.getLastFocused({}); + } + else { + try { + ownerWin = await browser.windows.get(ownerWinId).catch(_error => null); + } + catch(_error) { + } + if (!ownerWin) { + ownerWin = await browser.windows.getLastFocused({}); + } + } + + const type = this.DIALOG_READY_NOTIFICATION_TYPE; + const tryRepositionDialogToCenterOfOwner = this._tryRepositionDialogToCenterOfOwner; + browser.runtime.onMessage.addListener(function onMessage(message, sender) { + switch (message.type) { + case type: + browser.runtime.onMessage.removeListener(onMessage); + tryRepositionDialogToCenterOfOwner({ + ...message, + dialogWindowId: sender.tab.windowId, + }); + break; + } + }); + + return this._showInPopupInternal(ownerWin, { + ...params, + inject: { + ...(params.inject || {}), + __RichConfirm__reportScreenMessageType: type, + __RichConfirm__ownerWindowId: ownerWin.id, + }, + onShown: [ + ...(!params.onShown ? [] : Array.isArray(params.onShown) ? params.onShown : [params.onShown]), + (container, { __RichConfirm__reportScreenMessageType, __RichConfirm__ownerWindowId }) => { + setTimeout(() => { + // We cannot move this window by this callback function, thus I just send + // a request to update window position. + browser.runtime.sendMessage({ + type: __RichConfirm__reportScreenMessageType, + ownerWindowId: __RichConfirm__ownerWindowId, + availLeft: screen.availLeft, + availTop: screen.availTop, + availWidth: screen.availWidth, + availHeight: screen.availHeight, + }); + }, 0); + }, + ], + }); + } + + static async _tryRepositionDialogToCenterOfOwner({ dialogWindowId, ownerWindowId, availLeft, availTop, availWidth, availHeight }) { + const [dialogWin, ownerWin] = await Promise.all([ + browser.windows.get(dialogWindowId), + browser.windows.get(ownerWindowId), + ]); + const placedOnOwner = ( + dialogWin.left + dialogWin.width - (dialogWin.width / 2) < ownerWin.left && + dialogWin.top + dialogWin.height - (dialogWin.height / 2) < ownerWin.top && + dialogWin.left + (dialogWin.width / 2) < ownerWin.left + ownerWin.width && + dialogWin.top + (dialogWin.height / 2) < ownerWin.top + ownerWin.height + ); + const placedInsideViewArea = ( + dialogWin.left >= availLeft && + dialogWin.top >= availTop && + dialogWin.left + dialogWin.width <= availLeft + availWidth && + dialogWin.top + dialogWin.height <= availTop + availHeight + ); + if (placedOnOwner && placedInsideViewArea) + return; + + const top = ownerWin.top + Math.round((ownerWin.height - dialogWin.height) / 2); + const left = ownerWin.left + Math.round((ownerWin.width - dialogWin.width) / 2); + return browser.windows.update(dialogWin.id, { + left: Math.min(availLeft + availWidth - dialogWin.width, Math.max(availLeft, left)), + top: Math.min(availTop + availHeight - dialogWin.height, Math.max(availTop, top)), + }); + } + + // Workaround for a problem on an overload situation. + // When the system is in overload, the promise returned by browser.windows.create() + // won't be resolved forever (until the window is closed). + // So, we detect the opened window without the promise in different way + // based on its unique URL. + static async _safeCreateWindow(params) { + const existingWindowIds = new Set((await browser.windows.getAll()).map(win => win.id)); + // We must not add any extra query or hash for "about:blank", because it is very special URL. + // Extension with permission can inject arbitrary script to an "about:blank" page, + // but injection will fail for URIs like "about:blank#..." with missing host permission. + // Moreover, dialog window with "about:blank" is used to avoid closed windows restoration. + const uniqueKeyParam = params.url == 'about:blank' ? null : `popup-id-for-${uniqueKey}=${parseInt(Math.random() * Math.pow(2, 16))}`; + const dialogUrl = !uniqueKeyParam ? params.url : params.url.replace(/[?#]|$/, matched => { + if (!matched) + return `#${uniqueKeyParam}`; + if (matched == '?') + return `?${uniqueKeyParam}&`; + return `?#{uniqueKeyParam}#`; + }); + let win; + const promisedWin = browser.windows.create({ + ...params, + url: dialogUrl, + }).then(resolvedWin => { + // The returned promise won't be resolved until the opened window become fucused. + console.log('RichConfirm._safeCreateWindow: promised window is resolved'); + win = resolvedWin; + }); + while (!win) { + await Promise.race([ + new Promise(async (resolve, _reject) => { + if (win) + return resolve(); + const windows = await browser.windows.getAll({ populate: true }); + if (win) + return resolve(); + for (const window of windows) { + if (existingWindowIds.has(window.id) || + (uniqueKeyParam ? !window.tabs[0].url.includes(uniqueKeyParam) : window.tabs[0].url != dialogUrl)) + continue; + + console.log('RichConfirm._safeCreateWindow: new window is detected'); + win = window; + resolve(); + return; + } + setTimeout(resolve, 150); + }), + promisedWin, + ]); + } + win.dialogUrl = dialogUrl; + return win; + } + + static async _showInPopupInternal(ownerWin, params) { + const minWidth = Math.max(ownerWin.width, Math.ceil(screen.availWidth / 3)); + const minHeight = Math.max(ownerWin.height, Math.ceil(screen.availHeight / 3)); + + const simulation = new this({ + ...params, + popup: true, + simulation: true + }); + simulation.buildUI(); + const simulatedContainer = simulation.ui.querySelector('.rich-confirm-row'); + simulatedContainer.style.minWidth = `${minWidth}px`; + simulatedContainer.style.minHeight = `${minHeight}px`; + await new Promise((resolve, _reject) => { + simulation.show({ + onShown() { + setTimeout(() => { + resolve(); + }, 0); + } + }); + }); + const simulatedDialog = simulation.ui.querySelector('.rich-confirm-dialog'); + const simulatedRect = simulatedDialog.getBoundingClientRect(); + + // Safe guard for scrollbar due to unexpected line breaks + const safetyFactor = 1.05; + const simulatedSize = { + width: Math.ceil(simulatedRect.width * safetyFactor), + height: Math.ceil(simulatedRect.height * safetyFactor) + }; + simulation.hide(); + // This dimension is not accurate because we must think about + // the size of the window frame, but currently I don't know how to + // calculate it here... + + simulatedSize.top = ownerWin.top + Math.floor((ownerWin.height - simulatedSize.height) / 2); + simulatedSize.left = ownerWin.left + Math.floor((ownerWin.width - simulatedSize.width) / 2); + + const url = params.url || 'about:blank'; + const fullUrl = /^about:/.test(url) || /^\w+:\/\//.test(url) ? + url : + `moz-extension://${location.host}/${url.replace(/^\//, '')}`; + const win = await this._safeCreateWindow({ + url: fullUrl, + type: 'popup', + ...simulatedSize + }); + const dialogUrl = win.dialogUrl || fullUrl; + // Due to a Firefox's bug we cannot open popup type window + // at specified position. + // https://bugzilla.mozilla.org/show_bug.cgi?id=1271047 + // Thus we need to move the window immediately after it is opened. + if (win.left + win.width - (win.width / 2) <= ownerWin.left || + win.top + win.height - (win.height / 2) <= ownerWin.top || + win.left + (win.width / 2) >= ownerWin.left + ownerWin.width || + win.top + (win.height / 2) >= ownerWin.top + ownerWin.height) { + // But, such a move will produce an annoying flash. + // So, I grudgingly accept the position of the dialog placed + // if the popup (partially or fully) covers the owner window. + browser.windows.update(win.id, { + top: simulatedSize.top, + left: simulatedSize.left + }); + } + const activeTab = win.tabs.find(tab => tab.active); + + const onFocusChanged = async windowId => { + if (!params.modal || + windowId != ownerWin.id) { + return; + } + console.log(`focus of the window ${ownerWin.id} which is the owner of a modal dialog ${win.id} is changed`); + const [updatedWin, updatedOwnerWin] = await Promise.all([ + browser.windows.get(win.id), + browser.windows.get(ownerWin.id), + ]); + if (updatedOwnerWin?.state == 'minimized') { + console.log(' => but the owner window is minimized'); + if (updatedWin.state != 'minimized') { + console.log(' => minimize the modal dialog also'); + browser.windows.update(win.id, { state: 'minimized' }); + } + return; + } + browser.windows.update(win.id, { focused: true }); + }; + browser.windows.onFocusChanged.addListener(onFocusChanged); + + // On Thunderbird, closing of a composition window won't notify "windows.onRemoved" events, so we need to listen "tabs.onRemoved" also. + let onWindowClosed, onTabClosed; + const promisedDismissed = new Promise((resolve, _reject) => { + onWindowClosed = windowId => { + if (win.closed) { + return; + } + switch (windowId) { + case ownerWin.id: + browser.windows.remove(win.id); + break; + case win.id: + win.closed = true; + resolve({ buttonIndex: -1 }); + break; + } + }; + onTabClosed = (_tabId, removeInfo) => { + if (win.closed || !removeInfo.isWindowClosing) { + return; + } + switch (removeInfo.windowId) { + case ownerWin.id: + browser.windows.remove(win.id); + break; + case win.id: + win.closed = true; + resolve({ buttonIndex: -1 }); + break; + } + }; + }); + browser.windows.onRemoved.addListener(onWindowClosed); + browser.tabs.onRemoved.addListener(onTabClosed); + + const result = await Promise.race([ + promisedDismissed, + (async () => { + try { + let onDialogOpenedCalled = false; + const frameSize = await new Promise((resolve, _reject) => { + let timeout; + const getFrameSize = function getFrameSize(title, uniqueKey) { + if (title) + document.title = title; + const classList = document.documentElement.classList; + classList.add(`rich-confirm-${uniqueKey}`); + classList.add('popup-window'); + return { + width: window.outerWidth - window.innerWidth, + height: window.outerHeight - window.innerHeight, + url: location.href + }; + }; + const onTabUpdated = (tabId, updateInfo, _tab) => { + if (updateInfo.status != 'complete' || + !browser.tabs.onUpdated.hasListener(onTabUpdated)) + return; + if (timeout) + clearTimeout(timeout); + if (typeof browser.tabs.executeScript == 'function') // Manifest V2 + browser.tabs.executeScript(tabId, { + code: `(${getFrameSize.toString()})( + ${JSON.stringify(params.title)}, + ${JSON.stringify(uniqueKey)} + );`, + matchAboutBlank: true, + runAt: 'document_start' + }).then(results => { + if (results[0].url != dialogUrl) + return; + browser.tabs.onUpdated.removeListener(onTabUpdated); + resolve(results[0]); + }); + else // Manifest V3 + browser.scripting.executeScript({ + target: { tabId }, + func: getFrameSize, + args: [params.title, uniqueKey], + }).then(injectionResults => { + const result = injectionResults[0].result; + if (result.url != dialogUrl) + return; + browser.tabs.onUpdated.removeListener(onTabUpdated); + resolve(result); + }); + + if (typeof params.onDialogOpened == 'function' && + !onDialogOpenedCalled) { + onDialogOpenedCalled = true; + params.onDialogOpened({ + close() { + browser.windows.remove(win.id); + }, + }); + } + }; + timeout = setTimeout(() => { + if (!browser.tabs.onUpdated.hasListener(onTabUpdated)) + return; + timeout = null; + if (typeof browser.tabs.executeScript == 'function') // Manifest V2 + browser.tabs.executeScript(activeTab.id, { + code: `(${getFrameSize.toString()})( + ${JSON.stringify(params.title)}, + ${JSON.stringify(uniqueKey)} + );`, + matchAboutBlank: true, + runAt: 'document_start' + }).then(results => { + if (results[0].url != dialogUrl) + return; + browser.tabs.onUpdated.removeListener(onTabUpdated); + resolve(results[0]); + }).catch(console.error); + else // Manifest V3 + browser.scripting.executeScript({ + target: { tabId: activeTab.id }, + func: getFrameSize, + args: [params.title, uniqueKey], + }).then(injectionResults => { + const result = injectionResults[0].result; + if (result.url != dialogUrl) + return; + browser.tabs.onUpdated.removeListener(onTabUpdated); + resolve(result); + }).catch(console.error); + + if (typeof params.onDialogOpened == 'function' && + !onDialogOpenedCalled) { + onDialogOpenedCalled = true; + params.onDialogOpened({ + close() { + browser.windows.remove(win.id); + }, + }); + } + }, 500); + browser.tabs.onUpdated.addListener(onTabUpdated, { + properties: ['status'], + tabId: activeTab.id + }); + }); + + if (typeof browser.tabs.setZoom == 'function') + await browser.tabs.setZoom(activeTab.id, 1); + return this.showInTab(activeTab.id, { + ...params, + popup: true, + onReady(dialogSize) { + const actualWidth = Math.min( + Math.ceil(dialogSize.width + frameSize.width), + Math.max( + ownerWin.left + ownerWin.width - simulatedSize.left, + minWidth + ) + ); + const actualHeight = Math.min( + Math.ceil(dialogSize.height + frameSize.height), + Math.max( + ownerWin.top + ownerWin.height - simulatedSize.top, + minHeight + ) + ); + // This won't be proceeded until the promise returned by windows.create() is resolved, + // even if it is already detected via windows.query()... so we'll see oddly sized window + // until it become focused. + browser.windows.update(win.id, { + width: actualWidth, + height: actualHeight, + // We should reposition the dialog at truly center of the + // owner window, but it will produce an annoying slip of + // the window, so I give up for now. + //top: Math.floor(ownerWin.top + ((ownerWin.height - actualHeight) / 2)), + //left: Math.floor(ownerWin.left + ((ownerWin.width - actualWidth) / 2)) + }); + } + }); + } + catch(error) { + console.error(error); + return null; + } + })() + ]); + + browser.windows.onFocusChanged.removeListener(onFocusChanged); + browser.windows.onRemoved.removeListener(onWindowClosed); + browser.tabs.onRemoved.removeListener(onTabClosed); + + if (!win.closed) { + // A window closed with a blank page won't appear + // in the "Recently Closed Windows" list. + const reloadWithBlank = function reloadWithBlank() { + location.replace('about:blank'); + }; + (typeof browser.tabs.executeScript == 'function' ? + browser.tabs.executeScript(activeTab.id, { // Manifest V2 + code: `(${reloadWithBlank.toString()})();`, + matchAboutBlank: true, + runAt: 'document_start' + }) : + browser.scripting.executeScript({ // Manifest V3 + target: { tabId: activeTab.id }, + func: reloadWithBlank, + })) + .then(() => { + browser.windows.remove(win.id); + }); + } + + return result; + } + }; + RichConfirm.uniqueKey = RichConfirm.prototype.uniqueKey = uniqueKey; + RichConfirm.DIALOG_READY_NOTIFICATION_TYPE = `__RichConfirm_${uniqueKey}__confirmation-dialog-ready`; + window.RichConfirm = RichConfirm; + return true; // this is required to run this script as a content script +})(parseInt(Math.random() * Math.pow(2, 16))); +export default RichConfirm; diff --git a/waterfox/browser/components/sidebar/extlib/TabFavIconHelper.js b/waterfox/browser/components/sidebar/extlib/TabFavIconHelper.js new file mode 100644 index 000000000000..3b654063630e --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/TabFavIconHelper.js @@ -0,0 +1,677 @@ +/* +# 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/. +*/ +/* + original: + http://github.com/piroor/webextensions-lib-tab-favicon-helper +*/ +'use strict'; + +const TabFavIconHelper = { + LAST_EFFECTIVE_FAVICON: 'last-effective-favIcon', + VALID_FAVICON_PATTERN: /^(about|app|chrome|data|file|ftp|https?|moz-extension|resource):/, + DRAWABLE_FAVICON_PATTERN: /^(https?|moz-extension|resource):/, + + // original: chrome://browser/content/aboutlogins/icons/favicon.svg + FAVICON_LOCKWISE: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`, + // original: chrome://browser/content/robot.ico + FAVICON_ROBOT: ` +data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACGFjVEwAAAASAAAAAJNtBPIAAAAaZmNUTAAAAAAAAAAQAAAAEAAAAAAAAAAALuAD6AABhIDeugAAALhJREFUOI2Nk8sNxCAMRDlGohauXFOMpfTiAlxICqAELltHLqlgctg1InzMRhpFAc+LGWTnmoeZYamt78zXdZmaQtQMADlnU0OIAlbmJUBEcO4bRKQY2rUXIPmAGnDuG/Bx3/fvOPVaDUg+oAPUf1PArIMCSD5glMEsUGaG+kyAFWIBaCsKuA+HGCNijLgP133XgOEtaPFMy2vUolEGJoCIzBmoRUR9+7rxj16DZaW/mgtmxnJ8V3oAnApQwNS5zpcAAAAaZmNUTAAAAAEAAAAQAAAAEAAAAAAAAAAAAB4D6AIB52fclgAAACpmZEFUAAAAAjiNY2AYBVhBc3Pzf2LEcGreqcbwH1kDNjHauWAUjAJyAADymxf9WF+u8QAAABpmY1RMAAAAAwAAABAAAAAQAAAAAAAAAAAAHgPoAgEK8Q9/AAAAFmZkQVQAAAAEOI1jYBgFo2AUjAIIAAAEEAAB0xIn4wAAABpmY1RMAAAABQAAABAAAAAQAAAAAAAAAAAAHgPoAgHnO30FAAAAQGZkQVQAAAAGOI1jYBieYKcaw39ixHCC/6cwFWMTw2rz/1MM/6Vu/f///xTD/51qEIwuRjsXILuEGLFRMApgAADhNCsVfozYcAAAABpmY1RMAAAABwAAABAAAAAQAAAAAAAAAAAAHgPoAgEKra7sAAAAFmZkQVQAAAAIOI1jYBgFo2AUjAIIAAAEEAABM9s3hAAAABpmY1RMAAAACQAAABAAAAAQAAAAAAAAAAAAHgPoAgHn3p+wAAAAKmZkQVQAAAAKOI1jYBgFWEFzc/N/YsRwat6pxvAfWQM2Mdq5YBSMAnIAAPKbF/1BhPl6AAAAGmZjVEwAAAALAAAAEAAAABAAAAAAAAAAAAAeA+gCAQpITFkAAAAWZmRBVAAAAAw4jWNgGAWjYBSMAggAAAQQAAHaszpmAAAAGmZjVEwAAAANAAAAEAAAABAAAAAAAAAAAAAeA+gCAeeCPiMAAABAZmRBVAAAAA44jWNgGJ5gpxrDf2LEcIL/pzAVYxPDavP/Uwz/pW79////FMP/nWoQjC5GOxcgu4QYsVEwCmAAAOE0KxUmBL0KAAAAGmZjVEwAAAAPAAAAEAAAABAAAAAAAAAAAAAeA+gCAQoU7coAAAAWZmRBVAAAABA4jWNgGAWjYBSMAggAAAQQAAEpOBELAAAAGmZjVEwAAAARAAAAEAAAABAAAAAAAAAAAAAeA+gCAeYVWtoAAAAqZmRBVAAAABI4jWNgGAVYQXNz839ixHBq3qnG8B9ZAzYx2rlgFIwCcgAA8psX/WvpAecAAAAaZmNUTAAAABMAAAAQAAAAEAAAAAAAAAAAAB4D6AIBC4OJMwAAABZmZEFUAAAAFDiNY2AYBaNgFIwCCAAABBAAAcBQHOkAAAAaZmNUTAAAABUAAAAQAAAAEAAAAAAAAAAAAB4D6AIB5kn7SQAAAEBmZEFUAAAAFjiNY2AYnmCnGsN/YsRwgv+nMBVjE8Nq8/9TDP+lbv3///8Uw/+dahCMLkY7FyC7hBixUTAKYAAA4TQrFc+cEoQAAAAaZmNUTAAAABcAAAAQAAAAEAAAAAAAAAAAAB4D6AIBC98ooAAAABZmZEFUAAAAGDiNY2AYBaNgFIwCCAAABBAAASCZDI4AAAAaZmNUTAAAABkAAAAQAAAAEAAAAAAAAAAAAB4D6AIB5qwZ/AAAACpmZEFUAAAAGjiNY2AYBVhBc3Pzf2LEcGreqcbwH1kDNjHauWAUjAJyAADymxf9cjJWbAAAABpmY1RMAAAAGwAAABAAAAAQAAAAAAAAAAAAHgPoAgELOsoVAAAAFmZkQVQAAAAcOI1jYBgFo2AUjAIIAAAEEAAByfEBbAAAABpmY1RMAAAAHQAAABAAAAAQAAAAAAAAAAAAHgPoAgHm8LhvAAAAQGZkQVQAAAAeOI1jYBieYKcaw39ixHCC/6cwFWMTw2rz/1MM/6Vu/f///xTD/51qEIwuRjsXILuEGLFRMApgAADhNCsVlxR3/gAAABpmY1RMAAAAHwAAABAAAAAQAAAAAAAAAAAAHgPoAgELZmuGAAAAFmZkQVQAAAAgOI1jYBgFo2AUjAIIAAAEEAABHP5cFQAAABpmY1RMAAAAIQAAABAAAAAQAAAAAAAAAAAAHgPoAgHlgtAOAAAAKmZkQVQAAAAiOI1jYBgFWEFzc/N/YsRwat6pxvAfWQM2Mdq5YBSMAnIAAPKbF/0/MvDdAAAAAElFTkSuQmCC +`.trim(), + // original: chrome://browser/skin/controlcenter/dashboard.svg + FAVICON_DASHBOARD: ` + + + + +`, + // original: chrome://browser/skin/developer.svg + FAVICON_DEVELOPER: ` + + + +`, + // original: chrome://browser/skin/privatebrowsing/favicon.svg + FAVICON_PRIVATE_BROWSING: ` + + + + + + +`, + // original: chrome://browser/skin/settings.svg + FAVICON_SETTINGS: ` + + + +`, + // original: chrome://browser/skin/window.svg + FAVICON_WINDOW: ` + + + +`, + // original: chrome://devtools/skin/images/profiler-stopwatch.svg + FAVICON_PROFILER: ` + + + +`, + // original: chrome://global/skin/icons/performance.svg + FAVICON_PERFORMANCE: ` + + + + +`, + // original: chrome://global/skin/icons/warning.svg + FAVICON_WARNING: ` + + + +`, + // original: chrome://mozapps/skin/extensions/extensionGeneric-16.svg + FAVICON_EXTENSION: ` + + + +`, + // original: globe-16.svg + FAVICON_GLOBE: ` + + + +`, + + async _urlToKey(url) { // sha1 hash + const encoder = new TextEncoder(); + const data = encoder.encode(url); + + const hashBuffer = await crypto.subtle.digest('SHA-1', data); + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((byte) => byte.toString(16).padStart(2, '0')).join(''); + + return hashHex; + }, + + DB_NAME: 'TabFavIconHelper', + DB_VERSION: 2, + STORE_FAVICONS: 'favIcons', + STORE_EFFECTIVE_FAVICONS: 'effectiveFavIcons', + STORE_UNEFFECTIVE_FAVICONS: 'uneffectiveFavIcons', + EXPIRATION_TIME_IN_MSEC: 7 * 24 * 60 * 60 * 1000, // 7 days + + async _openDB() { + if (this._openedDB) + return this._openedDB; + return new Promise((resolve, _reject) => { + const request = indexedDB.open(this.DB_NAME, this.DB_VERSION); + + request.onerror = () => { + // This can fail if this is in a private window. + // See: https://github.com/piroor/treestyletab/issues/3387 + //reject(new Error('Failed to open database')); + resolve(null); + }; + + request.onsuccess = () => { + const db = request.result; + this._openedDB = db; + resolve(db); + }; + + request.onupgradeneeded = (event) => { + const db = event.target.result; + const objectStores = db.objectStoreNames; + + const needToUpgrade = event.oldVersion < this.DB_VERSION; + if (needToUpgrade) { + if (objectStores.contains(this.STORE_FAVICONS)) + db.deleteObjectStore(this.STORE_FAVICONS); + if (objectStores.contains(this.STORE_EFFECTIVE_FAVICONS)) + db.deleteObjectStore(this.STORE_EFFECTIVE_FAVICONS); + if (objectStores.contains(this.STORE_UNEFFECTIVE_FAVICONS)) + db.deleteObjectStore(this.STORE_UNEFFECTIVE_FAVICONS); + } + + if (needToUpgrade || + !objectStores.contains(this.STORE_FAVICONS)) { + const favIconsStore = db.createObjectStore(this.STORE_FAVICONS, { keyPath: 'key', unique: true }); + favIconsStore.createIndex('urlKey', 'urlKey', { unique: false }); + favIconsStore.createIndex('timestamp', 'timestamp'); + } + + if (needToUpgrade || + !objectStores.contains(this.STORE_EFFECTIVE_FAVICONS)) { + const effectiveFavIconsStore = db.createObjectStore(this.STORE_EFFECTIVE_FAVICONS, { keyPath: 'urlKey', unique: true }); + effectiveFavIconsStore.createIndex('timestamp', 'timestamp'); + effectiveFavIconsStore.createIndex('favIconKey', 'favIconKey', { unique: false }); + } + + if (needToUpgrade || + !objectStores.contains(this.STORE_UNEFFECTIVE_FAVICONS)) { + const uneffectiveFavIconsStore = db.createObjectStore(this.STORE_UNEFFECTIVE_FAVICONS, { keyPath: 'urlKey', unique: true }); + uneffectiveFavIconsStore.createIndex('timestamp', 'timestamp'); + uneffectiveFavIconsStore.createIndex('favIconKey', 'favIconKey', { unique: false }); + } + }; + }); + }, + + async _associateFavIconUrlToTabUrl({ favIconUrl, tabUrl, store } = {}) { + const [db, tabUrlKey, favIconKey] = await Promise.all([ + this._openDB(), + this._urlToKey(tabUrl), + this._urlToKey(favIconUrl), + ]); + if (!db) + return; + + try { + const transaction = db.transaction([store, this.STORE_FAVICONS], 'readwrite'); + const associationStore = transaction.objectStore(store); + const favIconStore = transaction.objectStore(this.STORE_FAVICONS); + const timestamp = Date.now(); + + const associationRequest = associationStore.put({ urlKey: tabUrlKey, favIconKey, timestamp }); + const favIconRequest = favIconStore.put({ key: favIconKey, url: favIconUrl, timestamp }); + + transaction.oncomplete = () => { + //db.close(); + this._reserveToExpireOldEntries(); + favIconUrl = undefined; + tabUrl = undefined; + store = undefined; + }; + + associationRequest.onerror = event => { + console.error(`Failed to associate favIconUrl ${favIconUrl} to tabUrl ${tabUrl} in the store ${store}`, event); + }; + + favIconRequest.onerror = event => { + console.error(`Failed to store favIconUrl ${favIconUrl} to tabUrl ${tabUrl} in the store ${store}`, event); + }; + } + catch(error) { + console.error(`Failed to associate favIconUrl ${favIconUrl} to tabUrl ${tabUrl} in the store ${store}`, error); + } + }, + + async _unassociateFavIconUrlFromTabUrl({ tabUrl, store } = {}) { + const [db, tabUrlKey] = await Promise.all([ + this._openDB(), + this._urlToKey(tabUrl), + ]); + if (!db) + return; + + try { + const transaction = db.transaction([store], 'readwrite'); + const associationStore = transaction.objectStore(store); + const unassociationRequest = associationStore.delete(tabUrlKey); + + transaction.oncomplete = () => { + //db.close(); + this._reserveToExpireOldEntries(); + tabUrl = undefined; + store = undefined; + }; + + unassociationRequest.onerror = event => { + console.error(`Failed to unassociate favIconUrl from tabUrl ${tabUrl} in the store ${store}`, event); + }; + } + catch(error) { + console.error(`Failed to unassociate favIconUrl from tabUrl ${tabUrl} in the store ${store}`, error); + } + }, + + async _getAssociatedFavIconUrlFromTabUrl({ tabUrl, store } = {}) { + return new Promise(async (resolve, _reject) => { + const [db, tabUrlKey] = await Promise.all([ + this._openDB(), + this._urlToKey(tabUrl), + ]); + if (!db) { + resolve(null); + return; + } + + try { + const transaction = db.transaction([store, this.STORE_FAVICONS], 'readonly'); + const associationStore = transaction.objectStore(store); + const favIconStore = transaction.objectStore(this.STORE_FAVICONS); + + const associationRequest = associationStore.get(tabUrlKey); + + associationRequest.onsuccess = () => { + const association = associationRequest.result; + if (!association) { + //console.log(`No associated favIconUrl for the tabUrl ${tabUrl} in the store ${store}`); + resolve(null); + return; + } + + const favIconRequest = favIconStore.get(association.favIconKey); + + favIconRequest.onsuccess = () => { + let favIcon = favIconRequest.result; + if (!favIcon) { + //console.log(`FavIcon data not found for the tabUrl ${tabUrl} in the store ${store}`); + resolve(null); + return; + } + if (favIcon.timestamp < Date.now() - this.EXPIRATION_TIME_IN_MSEC) { + //console.log(`FavIcon data is expired for the tabUrl ${tabUrl} in the store ${store}`); + this._reserveToExpireOldEntries(); + resolve(null); + return; + } + resolve(favIcon.url); + favIcon.url = undefined; + favIcon = undefined; + }; + + favIconRequest.onerror = event => { + console.error(`Failed to get favIconUrl from tabUrl ${tabUrl}`, event); + resolve(null); + }; + }; + + associationRequest.onerror = event => { + console.error(`Failed to get favIcon association from tabUrl ${tabUrl}`, event); + resolve(null); + }; + + transaction.oncomplete = () => { + //db.close(); + tabUrl = undefined; + store = undefined; + }; + } + catch(error) { + console.error('Failed to get from cache:', error); + resolve(null); + } + }); + }, + + async _reserveToExpireOldEntries() { + if (this._reservedExpiration) + clearTimeout(this._reservedExpiration); + this._reservedExpiration = setTimeout(() => { + this._reservedExpiration = null; + this._expireOldEntries(); + }, 500); + }, + async _expireOldEntries() { + return new Promise(async (resolve, reject) => { + const db = await this._openDB(); + if (!db) { + resolve(); + return; + } + + try { + const transaction = db.transaction([this.STORE_FAVICONS, this.STORE_EFFECTIVE_FAVICONS, this.STORE_UNEFFECTIVE_FAVICONS], 'readwrite'); + const favIconsStore = transaction.objectStore(this.STORE_FAVICONS); + const effectiveFavIconsStore = transaction.objectStore(this.STORE_EFFECTIVE_FAVICONS); + const uneffectiveFavIconsStore = transaction.objectStore(this.STORE_UNEFFECTIVE_FAVICONS); + + const favIconIndex = favIconsStore.index('timestamp'); + const effectiveFavIconIndex = effectiveFavIconsStore.index('timestamp'); + const uneffectiveFavIconIndex = uneffectiveFavIconsStore.index('timestamp'); + + const expirationTimestamp = Date.now() - this.EXPIRATION_TIME_IN_MSEC; + + const favIconRequest = favIconIndex.openCursor(IDBKeyRange.upperBound(expirationTimestamp)); + favIconRequest.onsuccess = (event) => { + const cursor = event.target.result; + if (!cursor) + return; + const key = cursor.primaryKey; + cursor.continue(); + const deleteRequest = favIconsStore.delete(key); + deleteRequest.onerror = event => { + console.error(`Failed to clear favicon index`, event); + }; + }; + favIconRequest.onerror = event => { + console.error(`Failed to retrieve favicon index`, event); + }; + + const effectiveFavIconRequest = effectiveFavIconIndex.openCursor(IDBKeyRange.upperBound(expirationTimestamp)); + effectiveFavIconRequest.onsuccess = (event) => { + const cursor = event.target.result; + if (!cursor) + return; + const url = cursor.primaryKey; + cursor.continue(); + const deleteRequest = effectiveFavIconsStore.delete(url); + deleteRequest.onerror = event => { + console.error(`Failed to clear effective favicon index`, event); + }; + }; + effectiveFavIconRequest.onerror = event => { + console.error(`Failed to retrieve effective favicon index`, event); + }; + + const uneffectiveFavIconRequest = uneffectiveFavIconIndex.openCursor(IDBKeyRange.upperBound(expirationTimestamp)); + uneffectiveFavIconRequest.onsuccess = (event) => { + const cursor = event.target.result; + if (!cursor) + return; + const url = cursor.primaryKey; + cursor.continue(); + const deleteRequest = uneffectiveFavIconsStore.delete(url); + deleteRequest.onerror = event => { + console.error(`Failed to clear uneffective favicon index`, event); + }; + }; + uneffectiveFavIconRequest.onerror = event => { + console.error(`Failed to retrieve uneffective favicon index`, event); + }; + + transaction.oncomplete = () => { + //db.close(); + resolve(); + }; + } + catch(error) { + console.error('Failed to expire old entries:', error); + reject(error); + } + }); + }, + + _tasks: [], + _processStep: 5, + FAVICON_SIZE: 16, + + _init() { + this._onTabUpdated = this._onTabUpdated.bind(this); + browser.tabs.onUpdated.addListener(this._onTabUpdated); + + this.canvas = document.createElement('canvas'); + this.canvas.width = this.canvas.height = this.FAVICON_SIZE; + this.canvas.setAttribute('style', ` + visibility: hidden; + pointer-events: none; + position: fixed + `); + document.body.appendChild(this.canvas); + + window.addEventListener('unload', () => { + browser.tabs.onUpdated.removeListener(this._onTabUpdated); + }, { once: true }); + }, + + _sessionAPIAvailable: ( + browser.sessions && + browser.sessions.getTabValue && + browser.sessions.setTabValue && + browser.sessions.removeTabValue + ), + + _addTask(task) { + this._tasks.push(task); + this._run(); + }, + + _run() { + if (this._running) + return; + this._running = true; + const processOneTask = () => { + if (this._tasks.length == 0) { + this._running = false; + } + else { + const tasks = this._tasks.splice(0, this._processStep); + while (tasks.length > 0) { + tasks.shift()(); + } + window.requestAnimationFrame(processOneTask); + } + }; + processOneTask(); + }, + + // public + loadToImage(params = {}) { + this._addTask(() => { + this._getEffectiveFavIconURL(params.tab, params.url) + .then(url => { + params.image.src = url; + params.image.classList.remove('error'); + url = undefined; + }, + _error => { + params.image.src = ''; + params.image.classList.add('error'); + }); + }); + }, + + // public + maybeImageTab(_tab) { // for backward compatibility + return false; + }, + + _getSafeFaviconUrl(url) { + switch (url) { + case 'chrome://browser/content/aboutlogins/icons/favicon.svg': + return this._getSVGDataURI(this.FAVICON_LOCKWISE); + case 'chrome://browser/content/robot.ico': + return this.FAVICON_ROBOT; + case 'chrome://browser/skin/controlcenter/dashboard.svg': + return this._getSVGDataURI(this.FAVICON_DASHBOARD); + case 'chrome://browser/skin/developer.svg': + return this._getSVGDataURI(this.FAVICON_DEVELOPER); + case 'chrome://browser/skin/privatebrowsing/favicon.svg': + return this._getSVGDataURI(this.FAVICON_PRIVATE_BROWSING); + case 'chrome://browser/skin/settings.svg': + return this._getSVGDataURI(this.FAVICON_SETTINGS); + case 'chrome://browser/skin/window.svg': + return this._getSVGDataURI(this.FAVICON_WINDOW); + case 'chrome://devtools/skin/images/profiler-stopwatch.svg': + return this._getSVGDataURI(this.FAVICON_PROFILER); + case 'chrome://global/skin/icons/performance.svg': + return this._getSVGDataURI(this.FAVICON_PERFORMANCE); + case 'chrome://global/skin/icons/warning.svg': + return this._getSVGDataURI(this.FAVICON_WARNING); + case 'chrome://mozapps/skin/extensions/extensionGeneric-16.svg': + return this._getSVGDataURI(this.FAVICON_EXTENSION); + default: + if (/^chrome:\/\//.test(url) && + !/^chrome:\/\/branding\//.test(url)) + return this._getSVGDataURI(this.FAVICON_GLOBE); + break; + } + return url; + }, + _getSVGDataURI(svg) { + return `data:image/svg+xml,${encodeURIComponent(svg.trim())}`; + }, + + // public + async getLastEffectiveFavIconURL(tab) { + if (tab.favIconUrl?.startsWith('data:')) + return tab.favIconUrl; + + const uneffectiveFavIconUrl = await this._getAssociatedFavIconUrlFromTabUrl({ tabUrl: tab.url, store: this.STORE_UNEFFECTIVE_FAVICONS }); + if (uneffectiveFavIconUrl) + return null; + + const favIconUrl = await this._getAssociatedFavIconUrlFromTabUrl({ tabUrl: tab.url, store: this.STORE_EFFECTIVE_FAVICONS }); + if (favIconUrl) + return favIconUrl; + + if (!this._sessionAPIAvailable) + return null; + + const lastData = await browser.sessions.getTabValue(tab.id, this.LAST_EFFECTIVE_FAVICON); + return lastData && lastData.url == tab.url && lastData.favIconUrl; + }, + + async _getEffectiveFavIconURL(tab, favIconUrl = null) { + if (tab.favIconUrl?.startsWith('data:')) { + browser.sessions.removeTabValue(tab.id, this.LAST_EFFECTIVE_FAVICON); + this._unassociateFavIconUrlFromTabUrl({ tabUrl: tab.url, store: this.STORE_UNEFFECTIVE_FAVICONS }); + return tab.favIconUrl; + } + + return new Promise(async (resolve, reject) => { + favIconUrl = this._getSafeFaviconUrl(favIconUrl || tab.favIconUrl); + let storedFavIconUrl; + if (!favIconUrl && tab.discarded) { + // discarded tab doesn't have favIconUrl, so we should use cached data. + storedFavIconUrl = favIconUrl = await this.getLastEffectiveFavIconURL(tab); + } + + let loader, onLoad, onError; + const clear = (() => { + if (loader) { + loader.removeEventListener('load', onLoad, { once: true }); + loader.removeEventListener('error', onError, { once: true }); + } + loader = onLoad = onError = favIconUrl = storedFavIconUrl = undefined; + }); + + onLoad = async foundFavIconUrl => { + let dataURL = null; + if (this.DRAWABLE_FAVICON_PATTERN.test(favIconUrl)) { + const context = this.canvas.getContext('2d'); + context.clearRect(0, 0, this.FAVICON_SIZE, this.FAVICON_SIZE); + context.drawImage(loader, 0, 0, this.FAVICON_SIZE, this.FAVICON_SIZE); + try { + dataURL = this.canvas.toDataURL('image/png'); + } + catch(_error) { + // it can fail due to security reasons + } + } + const oldFavIconUrl = foundFavIconUrl || await this._getAssociatedFavIconUrlFromTabUrl({ tabUrl: tab.url, store: this.STORE_EFFECTIVE_FAVICONS }); + if (!oldFavIconUrl || + oldFavIconUrl != favIconUrl) { + if (this._sessionAPIAvailable) + browser.sessions.setTabValue(tab.id, this.LAST_EFFECTIVE_FAVICON, { + url: tab.url, + favIconUrl, + }); + } + this._associateFavIconUrlToTabUrl({ tabUrl: tab.url, favIconUrl, store: this.STORE_EFFECTIVE_FAVICONS }); + this._unassociateFavIconUrlFromTabUrl({ tabUrl: tab.url, store: this.STORE_UNEFFECTIVE_FAVICONS }); + resolve(dataURL || favIconUrl); + clear(); + }; + onError = async error => { + this._unassociateFavIconUrlFromTabUrl({ tabUrl: tab.url, store: this.STORE_EFFECTIVE_FAVICONS }); + this._associateFavIconUrlToTabUrl({ tabUrl: tab.url, favIconUrl, store: this.STORE_UNEFFECTIVE_FAVICONS }); + if (this._sessionAPIAvailable) + browser.sessions.removeTabValue(tab.id, this.LAST_EFFECTIVE_FAVICON); + clear(); + reject(error || new Error('No effective icon')); + }; + storedFavIconUrl = storedFavIconUrl || await this._getAssociatedFavIconUrlFromTabUrl({ tabUrl: tab.url, store: this.STORE_EFFECTIVE_FAVICONS }); + if (storedFavIconUrl) + return onLoad(storedFavIconUrl); + if (!favIconUrl || + !this.VALID_FAVICON_PATTERN.test(favIconUrl)) { + onError(); + return; + } + loader = new Image(); + if (/^https?:/.test(favIconUrl)) + loader.crossOrigin = 'anonymous'; + loader.addEventListener('load', () => onLoad(), { once: true }); + loader.addEventListener('error', onError, { once: true }); + try { + loader.src = favIconUrl; + } + catch(error) { + this._unassociateFavIconUrlFromTabUrl({ tabUrl: tab.url, store: this.STORE_EFFECTIVE_FAVICONS }); + this._associateFavIconUrlToTabUrl({ tabUrl: tab.url, favIconUrl, store: this.STORE_UNEFFECTIVE_FAVICONS }); + onError(error); + } + }); + }, + + _onTabUpdated(tabId, changeInfo, _tab) { + if (!this._hasFavIconInfo(changeInfo)) + return; + let timer = this._updatingTabs.get(tabId); + if (timer) + clearTimeout(timer); + // Updating of last effective favicon must be done after the loading + // of the tab itself is correctly done, to avoid cookie problems on + // some websites. + // See also: https://github.com/piroor/treestyletab/issues/2064 + timer = setTimeout(async () => { + this._updatingTabs.delete(tabId); + const tab = await browser.tabs.get(tabId); + if (!tab || + (changeInfo.favIconUrl && + tab.favIconUrl != changeInfo.favIconUrl) || + (changeInfo.url && + tab.url != changeInfo.url) || + !this._hasFavIconInfo(tab)) + return; // expired + + await this._getEffectiveFavIconURL( + tab, + changeInfo.favIconUrl + ).catch(_error => {}); + }, 5000); + this._updatingTabs.set(tabId, timer); + }, + _hasFavIconInfo(tabOrChangeInfo) { + return 'favIconUrl' in tabOrChangeInfo; + }, + _updatingTabs: new Map(), +}; +TabFavIconHelper._init(); // eslint-disable-line no-underscore-dangle +export default TabFavIconHelper; diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-colorpicker.css b/waterfox/browser/components/sidebar/extlib/codemirror-colorpicker.css new file mode 100644 index 000000000000..1fbb4645f586 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-colorpicker.css @@ -0,0 +1,1245 @@ +/* codemirror-colorpicker version 1.9.80 */ +/* codemirror colorview */ +.codemirror-colorview { + border: 1px solid #cecece; + position: relative; + display: inline-block; + -webkit-box-sizing: border-box; + box-sizing: border-box; + margin: 0px 2px; + width: 10px; + height: 10px; + cursor: pointer; + vertical-align: middle; + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAJElEQVQYV2NctWrVfwYkEBYWxojMZ6SDAmT7QGx0K1EcRBsFAADeG/3M/HteAAAAAElFTkSuQmCC") repeat; } + .codemirror-colorview .codemirror-colorview-background { + content: ""; + position: absolute; + left: 0px; + right: 0px; + bottom: 0px; + top: 0px; } + .codemirror-colorview:hover { + border-color: #494949; } + +/* codemirror-colorpicker */ +.codemirror-colorpicker { + position: relative; + width: 224px; + z-index: 1000; + display: inline-block; + border: 1px solid rgba(0, 0, 0, 0.2); + background-color: #fff; + border-radius: 3px; + -webkit-box-shadow: 0 0px 10px 2px rgba(0, 0, 0, 0.12); + box-shadow: 0 0px 10px 2px rgba(0, 0, 0, 0.12); + /* theme */ } + .codemirror-colorpicker > .arrow { + position: absolute; + top: -10px; + left: 7px; + width: 0; + height: 0; + border-left: 10px solid transparent; + border-right: 10px solid transparent; + display: none; + border-bottom: 10px solid rgba(0, 0, 0, 0.2); + pointer-events: none; } + .codemirror-colorpicker > .arrow:after { + position: absolute; + content: ""; + top: 1px; + left: -9px; + width: 0; + height: 0; + border-left: 9px solid transparent; + border-right: 9px solid transparent; + border-bottom: 9px solid white; } + .codemirror-colorpicker .colorpicker-body .arrow-button { + position: relative; + width: 10px; + height: 12px; + padding: 0px; + background-color: transparent; } + .codemirror-colorpicker .colorpicker-body .arrow-button:before { + content: ""; + display: inline-block; + position: absolute; + left: 0px; + right: 0px; + top: 0px; + height: 50%; + width: 0; + height: 0; + border-left: 3px solid transparent; + border-right: 3px solid transparent; + border-bottom: 3px solid black; + pointer-events: none; + margin: 2px; + -webkit-box-sizing: border-box; + box-sizing: border-box; } + .codemirror-colorpicker .colorpicker-body .arrow-button:after { + content: ""; + display: inline-block; + position: absolute; + left: 0px; + right: 0px; + bottom: 0px; + top: 50%; + width: 0; + height: 0; + border-left: 3px solid transparent; + border-right: 3px solid transparent; + border-top: 3px solid black; + pointer-events: none; + margin: 2px; + -webkit-box-sizing: border-box; + box-sizing: border-box; } + .codemirror-colorpicker .colorpicker-body .color { + position: relative; + height: 120px; + overflow: hidden; + cursor: pointer; } + .codemirror-colorpicker .colorpicker-body .color > .saturation { + position: relative; + width: 100%; + height: 100%; } + .codemirror-colorpicker .colorpicker-body .color > .saturation > .value { + position: relative; + width: 100%; + height: 100%; } + .codemirror-colorpicker .colorpicker-body .color > .saturation > .value > .drag-pointer { + position: absolute; + width: 10px; + height: 10px; + border-radius: 50%; + -webkit-transform: translateX(-50%) translateY(-50%); + transform: translateX(-50%) translateY(-50%); } + .codemirror-colorpicker .colorpicker-body .color > .saturation { + background-color: rgba(204, 154, 129, 0); + background-image: -webkit-gradient(linear, left top, right top, from(#FFF), to(rgba(204, 154, 129, 0))); + background-image: linear-gradient(to right, #FFF, rgba(204, 154, 129, 0)); + background-repeat: repeat-x; } + .codemirror-colorpicker .colorpicker-body .color > .saturation > .value { + background-image: -webkit-gradient(linear, left bottom, left top, from(#000000), to(rgba(204, 154, 129, 0))); + background-image: linear-gradient(to top, #000000, rgba(204, 154, 129, 0)); } + .codemirror-colorpicker .colorpicker-body .color > .saturation > .value > .drag-pointer { + border: 1px solid #fff; + -webkit-box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.05); + box-shadow: 0 0 2px 0 rgba(0, 0, 0, 0.05); } + .codemirror-colorpicker .colorpicker-body .control { + position: relative; + padding: 10px 0px 10px 0px; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; } + .codemirror-colorpicker .colorpicker-body .control.has-eyedropper { + padding-left: 30px; } + .codemirror-colorpicker .colorpicker-body .control.has-eyedropper .el-cp-color-control__left { + position: absolute; + left: 12px; + top: 20px; + width: 30px; + height: 30px; + border-radius: 50%; + -webkit-box-sizing: border-box; + box-sizing: border-box; } + .codemirror-colorpicker .colorpicker-body .control.has-eyedropper > .color, .codemirror-colorpicker .colorpicker-body .control.has-eyedropper > .empty { + left: 45px; } + .codemirror-colorpicker .colorpicker-body .control > .color, .codemirror-colorpicker .colorpicker-body .control > .empty { + position: absolute; + left: 12px; + top: 14px; + width: 30px; + height: 30px; + border-radius: 50%; + -webkit-box-sizing: border-box; + box-sizing: border-box; } + .codemirror-colorpicker .colorpicker-body .control > .color { + border: 1px solid rgba(0, 0, 0, 0.1); } + .codemirror-colorpicker .colorpicker-body .control > .hue { + position: relative; + padding: 6px 16px; + margin: 0px 0px 0px 42px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + cursor: pointer; } + .codemirror-colorpicker .colorpicker-body .control > .hue > .hue-container { + position: relative; + width: 100%; + height: 10px; + border-radius: 3px; } + .codemirror-colorpicker .colorpicker-body .control > .opacity { + position: relative; + padding: 3px 16px; + margin: 0px 0px 0px 42px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + cursor: pointer; } + .codemirror-colorpicker .colorpicker-body .control > .opacity > .opacity-container { + position: relative; + width: 100%; + height: 10px; + border-radius: 3px; } + .codemirror-colorpicker .colorpicker-body .control .drag-bar, .codemirror-colorpicker .colorpicker-body .control .drag-bar2 { + position: absolute; + cursor: pointer; + top: 50%; + left: 0px; + -webkit-transform: translateX(-50%) translateY(-50%); + transform: translateX(-50%) translateY(-50%); + width: 12px; + height: 12px; + border-radius: 50%; } + .codemirror-colorpicker .colorpicker-body .control > .hue > .hue-container { + background: -webkit-gradient(linear, left top, right top, from(#ff0000), color-stop(17%, #ffff00), color-stop(33%, #00ff00), color-stop(50%, #00ffff), color-stop(67%, #0000ff), color-stop(83%, #ff00ff), to(#ff0000)); + background: linear-gradient(to right, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); } + .codemirror-colorpicker .colorpicker-body .control > .opacity > .opacity-container { + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAJElEQVQYV2NctWrVfwYkEBYWxojMZ6SDAmT7QGx0K1EcRBsFAADeG/3M/HteAAAAAElFTkSuQmCC") repeat; } + .codemirror-colorpicker .colorpicker-body .control > .opacity > .opacity-container > .color-bar { + position: absolute; + display: block; + content: ""; + left: 0px; + right: 0px; + bottom: 0px; + top: 0px; } + .codemirror-colorpicker .colorpicker-body .control > .empty { + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAJElEQVQYV2NctWrVfwYkEBYWxojMZ6SDAmT7QGx0K1EcRBsFAADeG/3M/HteAAAAAElFTkSuQmCC") repeat; } + .codemirror-colorpicker .colorpicker-body .control .drag-bar, + .codemirror-colorpicker .colorpicker-body .control .drag-bar2 { + border: 1px solid rgba(0, 0, 0, 0.05); + -webkit-box-shadow: 2px 2px 2px 0px rgba(0, 0, 0, 0.2); + box-shadow: 2px 2px 2px 0px rgba(0, 0, 0, 0.2); + background-color: #fefefe; } + .codemirror-colorpicker .colorpicker-body .information { + /*border-top: 1px solid #e8e8e8;*/ + position: relative; + -webkit-box-sizing: padding-box; + box-sizing: padding-box; } + .codemirror-colorpicker .colorpicker-body .information > input { + position: absolute; + font-size: 10px; + height: 20px; + bottom: 20px; + padding: 0 0 0 2px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; } + .codemirror-colorpicker .colorpicker-body .information > input[type=number] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; } + .codemirror-colorpicker .colorpicker-body .information > input[type=number]::-webkit-inner-spin-button, .codemirror-colorpicker .colorpicker-body .information > input[type=number]::-webkit-outer-spin-button { + -webkit-appearance: none; + appearance: none; + margin: 0; } + .codemirror-colorpicker .colorpicker-body .information.hex > .information-item.hex { + display: -webkit-box; + display: -ms-flexbox; + display: flex; } + .codemirror-colorpicker .colorpicker-body .information.rgb > .information-item.rgb { + display: -webkit-box; + display: -ms-flexbox; + display: flex; } + .codemirror-colorpicker .colorpicker-body .information.hsl > .information-item.hsl { + display: -webkit-box; + display: -ms-flexbox; + display: flex; } + .codemirror-colorpicker .colorpicker-body .information > .information-item { + display: none; + position: relative; + padding: 0px 5px; + padding-left: 9px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + margin-right: 40px; } + .codemirror-colorpicker .colorpicker-body .information > .information-item > .input-field { + display: block; + -webkit-box-flex: 1; + -ms-flex: 1; + flex: 1; + padding: 3px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + position: relative; } + .codemirror-colorpicker .colorpicker-body .information > .information-item > .input-field > .title { + text-align: center; + font-size: 12px; + color: #a9a9a9; + padding-top: 2px; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; } + .codemirror-colorpicker .colorpicker-body .information > .information-item > .input-field input { + text-align: center; + width: 100%; + padding: 3px; + height: 21px; + font-size: 11px; + color: #333; + -webkit-box-sizing: border-box; + box-sizing: border-box; + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; + border: 1px solid #cbcbcb; + border-radius: 2px; } + .codemirror-colorpicker .colorpicker-body .information > .information-item > .input-field input[type=number] { + -webkit-appearance: none; + -moz-appearance: none; + appearance: none; } + .codemirror-colorpicker .colorpicker-body .information > .information-item > .input-field input[type=number]::-webkit-inner-spin-button, .codemirror-colorpicker .colorpicker-body .information > .information-item > .input-field input[type=number]::-webkit-outer-spin-button { + -webkit-appearance: none; + appearance: none; + margin: 0; } + .codemirror-colorpicker .colorpicker-body .information > .information-item > .input-field.hsl-l input[type=number], .codemirror-colorpicker .colorpicker-body .information > .information-item > .input-field.hsl-s input[type=number] { + padding-left: 1px; + padding-right: 10px; } + .codemirror-colorpicker .colorpicker-body .information > .information-item > .input-field .postfix { + display: inline-block; + position: absolute; + right: 3px; + top: 2px; + height: 21px; + line-height: 2; + padding: 2px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + text-align: center; + font-size: 11px; } + .codemirror-colorpicker .colorpicker-body .information > .information-change { + position: absolute; + display: block; + width: 40px; + top: 0px; + right: 0px; + bottom: 0px; + text-align: center; + -webkit-box-sizing: border-box; + box-sizing: border-box; + padding-top: 5px; } + .codemirror-colorpicker .colorpicker-body .information > .information-change > .format-change-button { + -webkit-box-sizing: border-box; + box-sizing: border-box; + background: transparent; + border: 0px; + cursor: pointer; + outline: none; } + .codemirror-colorpicker .colorpicker-body .information > .title { + color: #a3a3a3; } + .codemirror-colorpicker .colorpicker-body .information > .input { + color: #333; } + .codemirror-colorpicker .colorpicker-body .colorsets { + border-top: 1px solid #e2e2e2; } + .codemirror-colorpicker .colorpicker-body .colorsets > .menu { + float: right; + padding: 10px 5px; + padding-right: 15px; } + .codemirror-colorpicker .colorpicker-body .colorsets > .menu button { + border: 0px; + font-size: 14px; + font-weight: 300; + font-family: serif, sans-serif; + outline: none; + cursor: pointer; } + .codemirror-colorpicker .colorpicker-body .colorsets > .color-list { + margin-right: 30px; + display: block; + padding: 12px 0px 0px 12px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + line-height: 0; } + .codemirror-colorpicker .colorpicker-body .colorsets > .color-list .color-item { + width: 13px; + height: 13px; + border-radius: 2px; + display: inline-block; + margin-right: 12px; + margin-bottom: 12px; + position: relative; + background-size: contain; + overflow: hidden; + -webkit-box-sizing: border-box; + box-sizing: border-box; + cursor: pointer; + vertical-align: middle; } + .codemirror-colorpicker .colorpicker-body .colorsets > .color-list .color-item:hover { + -webkit-transform: scale(1.2); + transform: scale(1.2); } + .codemirror-colorpicker .colorpicker-body .colorsets > .color-list .color-item .empty { + position: absolute; + left: 0px; + top: 0px; + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAJElEQVQYV2NctWrVfwYkEBYWxojMZ6SDAmT7QGx0K1EcRBsFAADeG/3M/HteAAAAAElFTkSuQmCC") repeat; + width: 100%; + height: 100%; + padding: 0px; + margin: 0px; + pointer-events: none; } + .codemirror-colorpicker .colorpicker-body .colorsets > .color-list .color-item .color-view { + position: absolute; + left: 0px; + top: 0px; + width: 100%; + height: 100%; + padding: 0px; + margin: 0px; + pointer-events: none; + border: 1px solid rgba(0, 0, 0, 0.1); + -webkit-box-sizing: border-box; + box-sizing: border-box; } + .codemirror-colorpicker .colorpicker-body .colorsets > .color-list .add-color-item { + width: 13px; + height: 13px; + display: inline-block; + margin-right: 12px; + margin-bottom: 12px; + cursor: pointer; + line-height: 1; + text-align: center; + font-size: 16px; + font-weight: 400; + font-family: serif,sans-serif; + color: #8e8e8e; + vertical-align: middle; } + .codemirror-colorpicker .colorpicker-body .color-chooser { + position: absolute; + left: 0px; + right: 0px; + bottom: 0px; + top: 0px; + opacity: 0; + background-color: rgba(0, 0, 0, 0.5); + -webkit-transition: opacity 0.05s ease-out; + transition: opacity 0.05s ease-out; + pointer-events: none; } + .codemirror-colorpicker .colorpicker-body .color-chooser.open { + opacity: 1; + pointer-events: all; } + .codemirror-colorpicker .colorpicker-body .color-chooser .color-chooser-container { + position: absolute; + top: 120px; + left: 0px; + right: 0px; + bottom: 0px; + background-color: white; } + .codemirror-colorpicker .colorpicker-body .color-chooser .color-chooser-container .colorsets-item-header { + position: absolute; + top: 0px; + left: 0px; + right: 0px; + height: 34px; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + padding: 3px 0px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + border-bottom: 1px solid rgba(0, 0, 0, 0.2); } + .codemirror-colorpicker .colorpicker-body .color-chooser .color-chooser-container .colorsets-item-header .title { + -webkit-box-flex: 2; + -ms-flex: 2; + flex: 2; + font-weight: bold; + font-size: 15px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + margin-right: 30px; + vertical-align: middle; + margin: 0px; + padding: 5px; + padding-left: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #000; + text-align: left; } + .codemirror-colorpicker .colorpicker-body .color-chooser .color-chooser-container .colorsets-item-header .items { + -webkit-box-flex: 1; + -ms-flex: 1; + flex: 1; + text-align: right; + padding-right: 10px; + display: block; + height: 100%; + line-height: 2; + cursor: pointer; } + .codemirror-colorpicker .colorpicker-body .color-chooser .color-chooser-container .colorsets-list { + position: absolute; + top: 34px; + left: 0px; + right: 0px; + bottom: 0px; + overflow: auto; } + .codemirror-colorpicker .colorpicker-body .color-chooser .color-chooser-container .colorsets-list .colorsets-item { + cursor: pointer; + display: -webkit-box; + display: -ms-flexbox; + display: flex; + padding: 3px 0px; + border-bottom: 1px solid rgba(0, 0, 0, 0.1); } + .codemirror-colorpicker .colorpicker-body .color-chooser .color-chooser-container .colorsets-list .colorsets-item:hover { + background-color: rgba(0, 0, 0, 0.05); } + .codemirror-colorpicker .colorpicker-body .color-chooser .color-chooser-container .colorsets-list .colorsets-item .title { + -webkit-box-flex: 2; + -ms-flex: 2; + flex: 2; + font-size: 14px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + margin-right: 30px; + vertical-align: middle; + pointer-events: none; + margin: 0px; + padding: 5px; + padding-left: 14px; + font-weight: normal; + font-size: 13px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: #000; + text-align: left; } + .codemirror-colorpicker .colorpicker-body .color-chooser .color-chooser-container .colorsets-list .colorsets-item .items { + -webkit-box-flex: 3; + -ms-flex: 3; + flex: 3; + display: block; + height: 100%; + line-height: 1.6; + cursor: pointer; + pointer-events: none; } + .codemirror-colorpicker .colorpicker-body .color-chooser .color-chooser-container .colorsets-list .colorsets-item .items .color-item { + width: 13px; + height: 13px; + border-radius: 3px; + display: inline-block; + margin-right: 10px; + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAJElEQVQYV2NctWrVfwYkEBYWxojMZ6SDAmT7QGx0K1EcRBsFAADeG/3M/HteAAAAAElFTkSuQmCC") repeat; + background-size: contain; + border: 1px solid #dddddd; + overflow: hidden; + -webkit-box-sizing: border-box; + box-sizing: border-box; + cursor: pointer; + vertical-align: middle; } + .codemirror-colorpicker .colorpicker-body .color-chooser .color-chooser-container .colorsets-list .colorsets-item .items .color-item .color-view { + width: 100%; + height: 100%; + padding: 0px; + margin: 0px; + pointer-events: none; } + .codemirror-colorpicker.sketch { + border-radius: 5px; } + .codemirror-colorpicker.sketch > .colorpicker-body > .color { + margin: 10px 10px 2px 10px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + height: 150px; } + .codemirror-colorpicker.sketch > .colorpicker-body > .control { + padding: 0px; } + .codemirror-colorpicker.sketch > .colorpicker-body > .control.has-eyedropper { + padding-left: 30px; } + .codemirror-colorpicker.sketch > .colorpicker-body > .control.has-eyedropper .el-cp-color-control__left { + top: 4px; } + .codemirror-colorpicker.sketch > .colorpicker-body > .control > .color, .codemirror-colorpicker.sketch > .colorpicker-body > .control > .empty { + position: absolute; + right: 10px; + left: auto; + top: 1px; + width: 26px; + height: 26px; + border-radius: 2px; + -webkit-box-sizing: border-box; + box-sizing: border-box; } + .codemirror-colorpicker.sketch > .colorpicker-body > .control > .color { + -webkit-box-shadow: inset 0px 0px 1px 0px rgba(0, 0, 0, 0.5); + box-shadow: inset 0px 0px 1px 0px rgba(0, 0, 0, 0.5); } + .codemirror-colorpicker.sketch > .colorpicker-body > .control > .hue { + position: relative; + padding: 2px 2px 2px 10px; + margin: 0px 38px 0px 0px; } + .codemirror-colorpicker.sketch > .colorpicker-body > .control > .hue > .hue-container { + border-radius: 0px; } + .codemirror-colorpicker.sketch > .colorpicker-body > .control > .opacity { + position: relative; + padding: 2px 2px 2px 10px; + margin: 0px 38px 0px 0px; } + .codemirror-colorpicker.sketch > .colorpicker-body > .control > .opacity > .opacity-container { + border-radius: 0px; } + .codemirror-colorpicker.sketch > .colorpicker-body > .control .drag-bar, .codemirror-colorpicker.sketch > .colorpicker-body > .control .drag-bar2 { + border-radius: 0px; + top: 50%; + left: 0px; + width: 2px; + height: 50%; + -webkit-transform: translateX(-50%) translateY(-50%); + transform: translateX(-50%) translateY(-50%); + border-radius: 1px; + bottom: 1px !important; } + .codemirror-colorpicker.sketch > .colorpicker-body > .control .drag-bar.first, .codemirror-colorpicker.sketch > .colorpicker-body > .control .drag-bar2.first { + left: 0px; + -webkit-transform: translateX(50%) translateY(-50%) !important; + transform: translateX(50%) translateY(-50%) !important; } + .codemirror-colorpicker.sketch > .colorpicker-body > .control .drag-bar.last, .codemirror-colorpicker.sketch > .colorpicker-body > .control .drag-bar2.last { + -webkit-transform: translateX(-150%) translateY(-50%) !important; + transform: translateX(-150%) translateY(-50%) !important; } + .codemirror-colorpicker.sketch > .colorpicker-body > .information .information-change { + display: none; } + .codemirror-colorpicker.sketch > .colorpicker-body > .information.rgb .information-item.rgb { + display: inherit; } + .codemirror-colorpicker.sketch > .colorpicker-body > .information.rgb .information-item.hsl { + display: none !important; } + .codemirror-colorpicker.sketch > .colorpicker-body > .information.hex .information-item.hex { + display: inherit; } + .codemirror-colorpicker.sketch > .colorpicker-body > .information.hex .information-item.hsl { + display: none !important; } + .codemirror-colorpicker.sketch > .colorpicker-body > .information.hsl .information-item.rgb { + display: none !important; } + .codemirror-colorpicker.sketch > .colorpicker-body > .information.hsl .information-item.hsl { + display: inherit; } + .codemirror-colorpicker.sketch > .colorpicker-body > .information .information-item { + display: -webkit-inline-box !important; + display: -ms-inline-flexbox !important; + display: inline-flex !important; + margin-right: 0px; } + .codemirror-colorpicker.sketch > .colorpicker-body > .information .information-item > .input-field { + padding-left: 0px; } + .codemirror-colorpicker.sketch > .colorpicker-body > .information .information-item > .input-field:last-child { + padding-right: 0px; } + .codemirror-colorpicker.sketch > .colorpicker-body > .information .information-item > .input-field > .title { + color: black; + font-size: 11px; + cursor: pointer; } + .codemirror-colorpicker.sketch > .colorpicker-body > .information .information-item > .input-field:last-child:not(:first-child) { + padding-right: 0px; } + .codemirror-colorpicker.sketch > .colorpicker-body > .information .information-item.hex { + width: 74px; + padding-right: 0px; + padding-left: 5px; } + .codemirror-colorpicker.sketch > .colorpicker-body > .information .information-item.rgb { + width: 140px; + padding-left: 0px; + padding-right: 0px; } + .codemirror-colorpicker.sketch > .colorpicker-body > .information .information-item.hsl { + display: none; + width: 140px; + padding-left: 0px; + padding-right: 0px; } + .codemirror-colorpicker.sketch > .colorpicker-body > .colorsets > .color-list { + margin-right: 0px; + padding-right: 12px; } + .codemirror-colorpicker.sketch > .colorpicker-body > .colorsets > .color-list .color-item { + width: 16px; + height: 16px; + border-radius: 3px; + margin-right: 9px; + margin-bottom: 10px; } + .codemirror-colorpicker.palette { + border-radius: 3px; + -webkit-box-shadow: none; + box-shadow: none; } + .codemirror-colorpicker.palette > .colorpicker-body > .color { + display: none; } + .codemirror-colorpicker.palette > .colorpicker-body > .control { + display: none; } + .codemirror-colorpicker.palette > .colorpicker-body > .information { + display: none; } + .codemirror-colorpicker.palette > .colorpicker-body > .colorsets { + -webkit-box-sizing: border-box; + box-sizing: border-box; + border-top: 0px; } + .codemirror-colorpicker.palette > .colorpicker-body > .colorsets > .color-list .color-item { + width: 15px; + height: 15px; + margin-right: 10px; + margin-bottom: 10px; } + .codemirror-colorpicker.palette > .colorpicker-body > .color-chooser { + display: none; + -webkit-box-sizing: border-box; + box-sizing: border-box; } + .codemirror-colorpicker.palette > .colorpicker-body > .color-chooser.open { + display: block; + top: -1px; + left: -1px; + right: -1px; + bottom: auto; + border-radius: 3px; + border: 1px solid #d8d8d8; + -webkit-box-shadow: 0 0px 10px 2px rgba(0, 0, 0, 0.12); + box-shadow: 0 0px 10px 2px rgba(0, 0, 0, 0.12); } + .codemirror-colorpicker.palette > .colorpicker-body > .color-chooser.open .color-chooser-container { + position: relative; + top: auto; + left: auto; + right: auto; + bottom: auto; + background-color: white; + -webkit-box-sizing: border-box; + box-sizing: border-box; + border-radius: 2px; } + .codemirror-colorpicker.palette > .colorpicker-body > .color-chooser.open .color-chooser-container .colorsets-item-header { + position: relative; + left: auto; + top: auto; + right: auto; + bottom: auto; + border-top-left-radius: 3px; + border-top-right-radius: 3px; } + .codemirror-colorpicker.palette > .colorpicker-body > .color-chooser.open .color-chooser-container .colorsets-list { + position: relative; + top: auto; + left: auto; + right: auto; + bottom: auto; + overflow: auto; } + .codemirror-colorpicker.palette > .colorpicker-body > .color-chooser.open .color-chooser-container .colorsets-list .colorsets-item:last-child { + border-bottom-left-radius: 3px; + border-bottom-right-radius: 3px; } + .codemirror-colorpicker.macos .colorpicker-body .wheel { + width: 224px; + height: 224px; + position: relative; + -webkit-box-sizing: border-box; + box-sizing: border-box; } + .codemirror-colorpicker.macos .colorpicker-body .wheel .wheel-canvas { + width: 214px; + height: 214px; + border-radius: 50%; + position: absolute; + left: 5px; + top: 5px; } + .codemirror-colorpicker.macos .colorpicker-body .wheel .drag-pointer { + display: inline-block; + position: absolute; + width: 10px; + height: 10px; + left: 50%; + top: 50%; + border: 1px solid white; + border-radius: 50%; + -webkit-transform: translateX(-50%) translateY(-50%); + transform: translateX(-50%) translateY(-50%); + z-index: 2; } + .codemirror-colorpicker.macos .control { + padding-top: 0px; } + .codemirror-colorpicker.macos .control > .color, .codemirror-colorpicker.macos .control > .empty { + top: 4px; } + .codemirror-colorpicker.macos .control.has-eyedropper { + padding-left: 30px; } + .codemirror-colorpicker.macos .control.has-eyedropper .el-cp-color-control__left { + top: 9px; } + .codemirror-colorpicker.macos .value { + position: relative; + padding: 6px 16px; + margin: 0px 0px 0px 42px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + cursor: pointer; } + .codemirror-colorpicker.macos .value > .value-container { + position: relative; + width: 100%; + height: 10px; + border-radius: 3px; + background-image: -webkit-gradient(linear, left top, right top, from(#000000), to(rgba(255, 255, 255, 0))); + background-image: linear-gradient(to right, #000000 0%, rgba(255, 255, 255, 0) 100%); + -webkit-box-sizing: border-box; + box-sizing: border-box; } + .codemirror-colorpicker.macos .value > .value-container .drag-bar { + position: absolute; + cursor: pointer; + top: 50%; + left: 0px; + -webkit-transform: translateX(-50%) translateY(-50%); + transform: translateX(-50%) translateY(-50%); + width: 12px; + height: 12px; + border-radius: 50%; } + .codemirror-colorpicker.mini { + width: 180px; + display: inline-block; } + .codemirror-colorpicker.mini .control { + padding: 0px; } + .codemirror-colorpicker.mini .control .hue, .codemirror-colorpicker.mini .control .opacity { + margin: 0px; + padding: 0px; } + .codemirror-colorpicker.mini .control .hue > .hue-container { + border-radius: 0px; + overflow: hidden; + height: 20px; } + .codemirror-colorpicker.mini .control .opacity > .opacity-container { + border-radius: 0px; + overflow: hidden; + height: 20px; } + .codemirror-colorpicker.mini .control .drag-bar, .codemirror-colorpicker.mini .control .drag-bar2 { + border: 0px; + background-color: transparent; + height: 100%; + width: 5px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + -webkit-box-shadow: none; + box-shadow: none; } + .codemirror-colorpicker.mini .control .drag-bar.last:before, .codemirror-colorpicker.mini .control .drag-bar.lastafter, .codemirror-colorpicker.mini .control .drag-bar2.last:before, .codemirror-colorpicker.mini .control .drag-bar2.lastafter { + left: 1px; } + .codemirror-colorpicker.mini .control .drag-bar.first:before, .codemirror-colorpicker.mini .control .drag-bar.first:after, .codemirror-colorpicker.mini .control .drag-bar2.first:before, .codemirror-colorpicker.mini .control .drag-bar2.first:after { + left: 3px; } + .codemirror-colorpicker.mini .control .drag-bar:before, .codemirror-colorpicker.mini .control .drag-bar2:before { + content: ""; + position: absolute; + left: 2px; + top: 0px; + width: 0; + height: 0; + -webkit-transform: translateX(-50%); + transform: translateX(-50%); + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-top: 4px solid black; } + .codemirror-colorpicker.mini .control .drag-bar:after, .codemirror-colorpicker.mini .control .drag-bar2:after { + content: ""; + position: absolute; + left: 2px; + bottom: 0px; + width: 0; + height: 0; + -webkit-transform: translateX(-50%); + transform: translateX(-50%); + border-left: 4px solid transparent; + border-right: 4px solid transparent; + border-bottom: 4px solid black; } + .codemirror-colorpicker.mini-vertical { + width: 180px; + display: inline-block; } + .codemirror-colorpicker.mini-vertical .color { + display: inline-block; + width: 140px; + height: 160px; + vertical-align: middle; } + .codemirror-colorpicker.mini-vertical .control { + height: 160px; + padding: 0px; + vertical-align: middle; + display: inline-block; } + .codemirror-colorpicker.mini-vertical .control .hue, .codemirror-colorpicker.mini-vertical .control .opacity { + margin: 0px; + padding: 0px; + width: 20px; + display: inline-block; + vertical-align: middle; + height: 100%; + position: relative; } + .codemirror-colorpicker.mini-vertical .control .hue > .hue-container { + border-radius: 0px; + overflow: hidden; + height: 100%; + background: -webkit-gradient(linear, left bottom, left top, from(#ff0000), color-stop(17%, #ffff00), color-stop(33%, #00ff00), color-stop(50%, #00ffff), color-stop(67%, #0000ff), color-stop(83%, #ff00ff), to(#ff0000)); + background: linear-gradient(to top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); } + .codemirror-colorpicker.mini-vertical .control .opacity > .opacity-container { + border-radius: 0px; + overflow: hidden; + height: 100%; + width: 20px; } + .codemirror-colorpicker.mini-vertical .control .drag-bar, .codemirror-colorpicker.mini-vertical .control .drag-bar2 { + border: 0px; + background-color: transparent; + height: 2px; + width: 100%; + -webkit-box-sizing: border-box; + box-sizing: border-box; + -webkit-box-shadow: none; + box-shadow: none; + -webkit-transform: none; + transform: none; } + .codemirror-colorpicker.mini-vertical .control .drag-bar.last:before, .codemirror-colorpicker.mini-vertical .control .drag-bar.last:after, .codemirror-colorpicker.mini-vertical .control .drag-bar2.last:before, .codemirror-colorpicker.mini-vertical .control .drag-bar2.last:after { + top: 2px; } + .codemirror-colorpicker.mini-vertical .control .drag-bar.first:before, .codemirror-colorpicker.mini-vertical .control .drag-bar.first:after, .codemirror-colorpicker.mini-vertical .control .drag-bar2.first:before, .codemirror-colorpicker.mini-vertical .control .drag-bar2.first:after { + top: -1px; } + .codemirror-colorpicker.mini-vertical .control .drag-bar:before, .codemirror-colorpicker.mini-vertical .control .drag-bar2:before { + content: ""; + position: absolute; + left: 0px; + top: 2px; + width: 0; + height: 0; + -webkit-transform: translateY(-50%); + transform: translateY(-50%); + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-left: 4px solid black; } + .codemirror-colorpicker.mini-vertical .control .drag-bar:after, .codemirror-colorpicker.mini-vertical .control .drag-bar2:after { + content: ""; + position: absolute; + top: 2px; + right: 0px; + width: 0; + height: 0; + -webkit-transform: translateY(-50%); + transform: translateY(-50%); + border-top: 4px solid transparent; + border-bottom: 4px solid transparent; + border-right: 4px solid black; } + .codemirror-colorpicker.ring .colorpicker-body > .color { + position: absolute; + width: 120px; + height: 120px; + left: 52px; + top: 52px; } + .codemirror-colorpicker.ring .colorpicker-body .wheel { + width: 224px; + height: 224px; + position: relative; + -webkit-box-sizing: border-box; + box-sizing: border-box; } + .codemirror-colorpicker.ring .colorpicker-body .wheel .wheel-canvas { + width: 214px; + height: 214px; + border-radius: 50%; + position: absolute; + left: 5px; + top: 5px; } + .codemirror-colorpicker.ring .colorpicker-body .wheel .drag-pointer { + display: inline-block; + position: absolute; + width: 10px; + height: 10px; + left: 50%; + top: 50%; + border: 1px solid white; + border-radius: 50%; + -webkit-transform: translateX(-50%) translateY(-50%); + transform: translateX(-50%) translateY(-50%); + z-index: 2; } + .codemirror-colorpicker.ring .control { + padding-top: 0px; } + .codemirror-colorpicker.ring .control .value { + display: none; } + .codemirror-colorpicker.ring .control > .color, .codemirror-colorpicker.ring .control > .empty { + top: -17px; + width: 30px; + height: 30px; + border-radius: 2px; } + .codemirror-colorpicker.ring .control.has-eyedropper { + padding-left: 30px; + padding-top: 10px; } + .codemirror-colorpicker.ring .control.has-eyedropper > .color, .codemirror-colorpicker.ring .control.has-eyedropper > .empty { + top: -2px; } + .codemirror-colorpicker.ring .control.has-eyedropper .el-cp-color-control__left { + top: 4px; } + .codemirror-colorpicker.xd { + display: inline-block; + padding-top: 12px; + width: 245px; } + .codemirror-colorpicker.xd .color { + display: inline-block; + margin-left: 12px; + margin-bottom: 12px; + width: 170px; + height: 170px; + vertical-align: middle; + border-radius: 3px; + overflow: hidden; + -webkit-box-sizing: border-box; + box-sizing: border-box; + border: 1px solid #cecece; } + .codemirror-colorpicker.xd .color > .saturation > .value > .drag-pointer { + border: 2px solid white; + width: 7px; + height: 7px; + -webkit-box-shadow: 0 0 1px 0px black, inset 0 0 1px 0px black; + box-shadow: 0 0 1px 0px black, inset 0 0 1px 0px black; } + .codemirror-colorpicker.xd .control { + height: 170px; + padding: 0px; + vertical-align: middle; + display: inline-block; + margin-right: 12px; + margin-bottom: 12px; } + .codemirror-colorpicker.xd .control .hue, .codemirror-colorpicker.xd .control .opacity { + margin: 0px; + padding: 0px; + width: 13px; + display: inline-block; + vertical-align: middle; + height: 100%; + position: relative; + overflow: hidden; + border-radius: 3px; + margin-left: 8px; } + .codemirror-colorpicker.xd .control .hue > .hue-container { + border-radius: 0px; + overflow: hidden; + height: 100%; + background: -webkit-gradient(linear, left bottom, left top, from(#ff0000), color-stop(17%, #ffff00), color-stop(33%, #00ff00), color-stop(50%, #00ffff), color-stop(67%, #0000ff), color-stop(83%, #ff00ff), to(#ff0000)); + background: linear-gradient(to top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); } + .codemirror-colorpicker.xd .control .opacity > .opacity-container { + border-radius: 0px; + overflow: hidden; + height: 100%; } + .codemirror-colorpicker.xd .control .drag-bar, .codemirror-colorpicker.xd .control .drag-bar2 { + border: 0px; + background-color: transparent; + border: 2px solid white; + -webkit-box-shadow: 0 0 1px 0px black, inset 0 0 1px 0px black; + box-shadow: 0 0 1px 0px black, inset 0 0 1px 0px black; + width: 10px; + height: 10px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + -webkit-transform: none; + transform: none; + overflow: hidden; + left: 50%; + -webkit-transform: translateX(-50%) translateY(-50%); + transform: translateX(-50%) translateY(-50%); } + .codemirror-colorpicker.xd .information { + margin-top: 5px; } + .codemirror-colorpicker.vscode { + width: 336px; + display: inline-block; + background-color: #333; + border: 1px solid #ececec; + -webkit-box-sizing: border-box; + box-sizing: border-box; + border-radius: 0px; } + .codemirror-colorpicker.vscode .colorpicker-body { + border-radius: 0px; + display: inline-block; } + .codemirror-colorpicker.vscode .colorpicker-body .color-view { + height: 34px; } + .codemirror-colorpicker.vscode .colorpicker-body .color-view.has-eyedropper { + display: -webkit-box; + display: -ms-flexbox; + display: flex; } + .codemirror-colorpicker.vscode .colorpicker-body .color-view.has-eyedropper .color-view-container { + width: 254px; + display: inline-block; } + .codemirror-colorpicker.vscode .colorpicker-body .color-view.has-eyedropper .el-cp-color-control__left { + float: right; + width: 80px; + text-align: center; + padding: 6px 0px; } + .codemirror-colorpicker.vscode .colorpicker-body .color-view.has-eyedropper .el-cp-color-control__left button { + display: inline-block; } + .codemirror-colorpicker.vscode .colorpicker-body .color-view.has-eyedropper .el-cp-color-control__left button svg path { + fill: white; } + .codemirror-colorpicker.vscode .colorpicker-body .color-view .color-view-container { + line-height: 34px; + font-size: 14px; + text-align: center; + width: 100%; + height: 100%; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + text-shadow: 0 0 3px #535353; + background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAAJElEQVQYV2NctWrVfwYkEBYWxojMZ6SDAmT7QGx0K1EcRBsFAADeG/3M/HteAAAAAElFTkSuQmCC") repeat; } + .codemirror-colorpicker.vscode .colorpicker-body .color-view .color-view-container .preview { + display: block; + height: 100%; } + .codemirror-colorpicker.vscode .colorpicker-body .color-tool { + padding: 8px; } + .codemirror-colorpicker.vscode .color { + display: inline-block; + width: 240px; + height: 160px; + vertical-align: middle; } + .codemirror-colorpicker.vscode .control { + height: 160px; + vertical-align: middle; + display: inline-block; + padding: 0px 0px 0px 4px; } + .codemirror-colorpicker.vscode .control .hue, .codemirror-colorpicker.vscode .control .opacity { + margin: 0px; + padding: 0px; + width: 30px; + display: inline-block; + vertical-align: middle; + height: 100%; + position: relative; } + .codemirror-colorpicker.vscode .control .hue { + padding-left: 5px; + width: 35px; } + .codemirror-colorpicker.vscode .control .hue > .hue-container { + border-radius: 0px; + height: 100%; + background: -webkit-gradient(linear, left bottom, left top, from(#ff0000), color-stop(17%, #ffff00), color-stop(33%, #00ff00), color-stop(50%, #00ffff), color-stop(67%, #0000ff), color-stop(83%, #ff00ff), to(#ff0000)); + background: linear-gradient(to top, #ff0000 0%, #ffff00 17%, #00ff00 33%, #00ffff 50%, #0000ff 67%, #ff00ff 83%, #ff0000 100%); } + .codemirror-colorpicker.vscode .control .opacity > .opacity-container { + border-radius: 0px; + height: 100%; + width: 30px; } + .codemirror-colorpicker.vscode .control .drag-bar, .codemirror-colorpicker.vscode .control .drag-bar2 { + background-color: transparent; + height: 5px; + width: 33px; + -webkit-box-sizing: border-box; + box-sizing: border-box; + -webkit-box-shadow: none; + box-shadow: none; + -webkit-transform: translateY(-50%) translateX(-2px); + transform: translateY(-50%) translateX(-2px); + border: 1px solid white; + border-radius: 0px; + -webkit-box-shadow: 0 0 2px 0 black, inset 0 0 0 0 black; + box-shadow: 0 0 2px 0 black, inset 0 0 0 0 black; } + .codemirror-colorpicker.box { + width: 420px; + border-radius: 10px; } + .codemirror-colorpicker.box .colorpicker-body { + display: grid; + padding: 10px 20px 10px 10px; + grid-template-columns: 200px 1fr; + -webkit-column-gap: 10px; + -moz-column-gap: 10px; + column-gap: 10px; + grid-template-rows: auto; } + .codemirror-colorpicker.box .colorpicker-body > .color { + height: 100%; + border-radius: 8px; + overflow: hidden; } + .codemirror-colorpicker.box .control { + padding: 0px !important; } + .codemirror-colorpicker.box .control > * { + vertical-align: middle; } + .codemirror-colorpicker.box .control .color-info { + position: relative; + height: 30px; + width: 30px; + display: inline-block; } + .codemirror-colorpicker.box .control .color-info > .color, .codemirror-colorpicker.box .control .color-info > .empty { + position: absolute; + width: 100%; + height: 100%; + border-radius: 50%; + -webkit-box-sizing: border-box; + box-sizing: border-box; } + .codemirror-colorpicker.box .control .color-info > .color { + border: 1px solid rgba(0, 0, 0, 0.1); } + .codemirror-colorpicker.box .control > .color, .codemirror-colorpicker.box .control > .empty { + top: 4px; } + .codemirror-colorpicker.box .control.has-eyedropper { + padding-left: 30px; } + .codemirror-colorpicker.box .control.has-eyedropper .el-cp-color-control__left { + display: inline-block; + width: 30px; + height: 30px; + position: relative; + top: auto; + left: auto; } + .codemirror-colorpicker.box .control .hue, .codemirror-colorpicker.box .control .opacity { + padding-left: 0px !important; + margin-left: 0px !important; + padding-right: 0px !important; } + .codemirror-colorpicker.box .value { + position: relative; + -webkit-box-sizing: border-box; + box-sizing: border-box; + cursor: pointer; } + .codemirror-colorpicker.box .value > .value-container { + position: relative; + width: 100%; + height: 10px; + border-radius: 3px; + background-image: -webkit-gradient(linear, left top, right top, from(#000000), to(rgba(255, 255, 255, 0))); + background-image: linear-gradient(to right, #000000 0%, rgba(255, 255, 255, 0) 100%); + -webkit-box-sizing: border-box; + box-sizing: border-box; } + .codemirror-colorpicker.box .value > .value-container .drag-bar { + position: absolute; + cursor: pointer; + top: 50%; + left: 0px; + -webkit-transform: translateX(-50%) translateY(-50%); + transform: translateX(-50%) translateY(-50%); + width: 12px; + height: 12px; + border-radius: 50%; } + .codemirror-colorpicker.box .information { + margin-top: 6px; } + .codemirror-colorpicker.box .information .information-change { + display: none; } + .codemirror-colorpicker.box .information > .information-item { + margin: 0px !important; + padding: 0px !important; } + .codemirror-colorpicker.box .colorsets { + border: 0px; + position: relative; } + .codemirror-colorpicker.box .colorsets .color-list { + padding: 0px !important; + margin-right: 0px !important; } + .codemirror-colorpicker.box .colorsets .color-list .current-color-sets .color-item { + width: 20px; + height: 20px; + margin-right: 4px !important; + margin-bottom: 4px !important; } + .codemirror-colorpicker.box .colorsets .menu { + float: none; + position: absolute; + right: -20px; + top: -15px; } + .codemirror-colorpicker.box .color-chooser .color-chooser-container { + top: 0px; + left: 200px; } + +.colorsets-contextmenu { + position: fixed; + padding-top: 4px; + padding-bottom: 4px; + border-radius: 6px; + background-color: #ececec; + border: 1px solid #cccccc; + display: none; + list-style: none; + font-size: 13px; + padding-left: 0px; + padding-right: 0px; } + .colorsets-contextmenu.show { + display: inline-block; } + .colorsets-contextmenu .menu-item { + padding: 2px 20px; + cursor: default; } + .colorsets-contextmenu .menu-item:hover { + background-color: #5ea3fb; + color: white; } + .colorsets-contextmenu.small .menu-item.small-hide { + display: none; } + +.el-cp-color-eyedropper button { + display: block; + width: 30px; + height: 30px; + padding: 0; + margin: -4px; + font-size: 0; + border: none; + border-radius: var(--size-radius); + cursor: pointer; + outline: none; + -webkit-box-sizing: border-box; + box-sizing: border-box; + background: none; + -webkit-transition: opacity var(--speed-focus) ease-out, -webkit-box-shadow var(--speed-focus) ease-out; + transition: opacity var(--speed-focus) ease-out, -webkit-box-shadow var(--speed-focus) ease-out; + transition: box-shadow var(--speed-focus) ease-out, opacity var(--speed-focus) ease-out; + transition: box-shadow var(--speed-focus) ease-out, opacity var(--speed-focus) ease-out, -webkit-box-shadow var(--speed-focus) ease-out; } + .el-cp-color-eyedropper button:focus-visible { + -webkit-box-shadow: 0 0 0 2px var(--color-key); + box-shadow: 0 0 0 2px var(--color-key); } + .el-cp-color-eyedropper button:active { + opacity: .5; } + +.el-cp-color-eyedropper svg { + display: block; + margin: 0 auto; + color: var(--color-fill); } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-colorpicker.js b/waterfox/browser/components/sidebar/extlib/codemirror-colorpicker.js new file mode 100644 index 000000000000..1065430aaf65 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-colorpicker.js @@ -0,0 +1,10122 @@ +/* codemirror-colorpicker version 1.9.80 */ +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global['codemirror-colorpicker'] = factory()); +}(this || self, (function () { 'use strict'; + +/** + * @method format + * + * convert color to format string + * + * // hex + * color.format({ r : 255, g : 255, b : 255, a: 1 }, 'hex') // #FFFFFFFF + * + * // rgb + * color.format({ r : 255, g : 255, b : 255 }, 'rgb') // rgba(255, 255, 255, 0.5); + * + * // rgba + * color.format({ r : 255, g : 255, b : 255, a : 0.5 }, 'rgb') // rgba(255, 255, 255, 0.5); + * + * @param {Object} obj obj has r, g, b and a attributes + * @param {"hex"/"rgb"} type format string type + * @returns {*} + */ +function format(obj, type) { + var defaultColor = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'rgba(0, 0, 0, 0)'; + + + if (Array.isArray(obj)) { + obj = { r: obj[0], g: obj[1], b: obj[2], a: obj[3] }; + } + + if (type == 'hex') { + return hex(obj); + } else if (type == 'rgb') { + return rgb(obj, defaultColor); + } else if (type == 'hsl') { + return hsl(obj); + } + + return obj; +} + +function hex(obj) { + if (Array.isArray(obj)) { + obj = { r: obj[0], g: obj[1], b: obj[2], a: obj[3] }; + } + + var r = obj.r.toString(16); + if (obj.r < 16) r = "0" + r; + + var g = obj.g.toString(16); + if (obj.g < 16) g = "0" + g; + + var b = obj.b.toString(16); + if (obj.b < 16) b = "0" + b; + + var alphaValue = ''; + if (obj.a < 1) { + var alpha = Math.floor(obj.a * 255); + var alphaValue = alpha.toString(16); + if (alpha < 16) alphaValue = "0" + alphaValue; + } + + return '#' + r + g + b + alphaValue; +} + +function rgb(obj) { + var defaultColor = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'rgba(0, 0, 0, 0)'; + + if (Array.isArray(obj)) { + obj = { r: obj[0], g: obj[1], b: obj[2], a: obj[3] }; + } + + if (typeof obj == 'undefined') { + return undefined; + } + + if (obj.a == 1 || typeof obj.a == 'undefined') { + if (isNaN(obj.r)) { + return defaultColor; + } + return 'rgb(' + obj.r + ',' + obj.g + ',' + obj.b + ')'; + } else { + return 'rgba(' + obj.r + ',' + obj.g + ',' + obj.b + ',' + obj.a + ')'; + } +} + +function hsl(obj) { + if (Array.isArray(obj)) { + obj = { r: obj[0], g: obj[1], b: obj[2], a: obj[3] }; + } + + if (obj.a == 1 || typeof obj.a == 'undefined') { + return 'hsl(' + obj.h + ',' + obj.s + '%,' + obj.l + '%)'; + } else { + return 'hsla(' + obj.h + ',' + obj.s + '%,' + obj.l + '%,' + obj.a + ')'; + } +} + +var formatter = { + format: format, + rgb: rgb, + hsl: hsl, + hex: hex +}; + +function round(n, k) { + k = typeof k == 'undefined' ? 1 : k; + return Math.round(n * k) / k; +} + +function degreeToRadian(angle) { + return angle * Math.PI / 180; +} + +/** + * + * convert radian to degree + * + * @param {*} radian + * @returns {Number} 0..360 + */ +function radianToDegree(radian) { + var angle = radian * 180 / Math.PI; + + if (angle < 0) { + // 각도가 0보다 작으면 360 에서 반전시킨다. + angle = 360 + angle; + } + + return angle; +} + +function getXInCircle(angle, radius) { + var centerX = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 0; + + return centerX + radius * Math.cos(degreeToRadian(angle)); +} + +function getYInCircle(angle, radius) { + var centerY = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 0; + + return centerY + radius * Math.sin(degreeToRadian(angle)); +} + +function getXYInCircle(angle, radius) { + var centerX = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 0; + var centerY = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 0; + + return { + x: getXInCircle(angle, radius, centerX), + y: getYInCircle(angle, radius, centerY) + }; +} + +function caculateAngle(rx, ry) { + return radianToDegree(Math.atan2(ry, rx)); +} + +var math = { + round: round, + radianToDegree: radianToDegree, + degreeToRadian: degreeToRadian, + getXInCircle: getXInCircle, + getYInCircle: getYInCircle, + caculateAngle: caculateAngle +}; + +var color_names = { aliceblue: "rgb(240, 248, 255)", antiquewhite: "rgb(250, 235, 215)", aqua: "rgb(0, 255, 255)", aquamarine: "rgb(127, 255, 212)", azure: "rgb(240, 255, 255)", beige: "rgb(245, 245, 220)", bisque: "rgb(255, 228, 196)", black: "rgb(0, 0, 0)", blanchedalmond: "rgb(255, 235, 205)", blue: "rgb(0, 0, 255)", blueviolet: "rgb(138, 43, 226)", brown: "rgb(165, 42, 42)", burlywood: "rgb(222, 184, 135)", cadetblue: "rgb(95, 158, 160)", chartreuse: "rgb(127, 255, 0)", chocolate: "rgb(210, 105, 30)", coral: "rgb(255, 127, 80)", cornflowerblue: "rgb(100, 149, 237)", cornsilk: "rgb(255, 248, 220)", crimson: "rgb(237, 20, 61)", cyan: "rgb(0, 255, 255)", darkblue: "rgb(0, 0, 139)", darkcyan: "rgb(0, 139, 139)", darkgoldenrod: "rgb(184, 134, 11)", darkgray: "rgb(169, 169, 169)", darkgrey: "rgb(169, 169, 169)", darkgreen: "rgb(0, 100, 0)", darkkhaki: "rgb(189, 183, 107)", darkmagenta: "rgb(139, 0, 139)", darkolivegreen: "rgb(85, 107, 47)", darkorange: "rgb(255, 140, 0)", darkorchid: "rgb(153, 50, 204)", darkred: "rgb(139, 0, 0)", darksalmon: "rgb(233, 150, 122)", darkseagreen: "rgb(143, 188, 143)", darkslateblue: "rgb(72, 61, 139)", darkslategray: "rgb(47, 79, 79)", darkslategrey: "rgb(47, 79, 79)", darkturquoise: "rgb(0, 206, 209)", darkviolet: "rgb(148, 0, 211)", deeppink: "rgb(255, 20, 147)", deepskyblue: "rgb(0, 191, 255)", dimgray: "rgb(105, 105, 105)", dimgrey: "rgb(105, 105, 105)", dodgerblue: "rgb(30, 144, 255)", firebrick: "rgb(178, 34, 34)", floralwhite: "rgb(255, 250, 240)", forestgreen: "rgb(34, 139, 34)", fuchsia: "rgb(255, 0, 255)", gainsboro: "rgb(220, 220, 220)", ghostwhite: "rgb(248, 248, 255)", gold: "rgb(255, 215, 0)", goldenrod: "rgb(218, 165, 32)", gray: "rgb(128, 128, 128)", grey: "rgb(128, 128, 128)", green: "rgb(0, 128, 0)", greenyellow: "rgb(173, 255, 47)", honeydew: "rgb(240, 255, 240)", hotpink: "rgb(255, 105, 180)", indianred: "rgb(205, 92, 92)", indigo: "rgb(75, 0, 130)", ivory: "rgb(255, 255, 240)", khaki: "rgb(240, 230, 140)", lavender: "rgb(230, 230, 250)", lavenderblush: "rgb(255, 240, 245)", lawngreen: "rgb(124, 252, 0)", lemonchiffon: "rgb(255, 250, 205)", lightblue: "rgb(173, 216, 230)", lightcoral: "rgb(240, 128, 128)", lightcyan: "rgb(224, 255, 255)", lightgoldenrodyellow: "rgb(250, 250, 210)", lightgreen: "rgb(144, 238, 144)", lightgray: "rgb(211, 211, 211)", lightgrey: "rgb(211, 211, 211)", lightpink: "rgb(255, 182, 193)", lightsalmon: "rgb(255, 160, 122)", lightseagreen: "rgb(32, 178, 170)", lightskyblue: "rgb(135, 206, 250)", lightslategray: "rgb(119, 136, 153)", lightslategrey: "rgb(119, 136, 153)", lightsteelblue: "rgb(176, 196, 222)", lightyellow: "rgb(255, 255, 224)", lime: "rgb(0, 255, 0)", limegreen: "rgb(50, 205, 50)", linen: "rgb(250, 240, 230)", magenta: "rgb(255, 0, 255)", maroon: "rgb(128, 0, 0)", mediumaquamarine: "rgb(102, 205, 170)", mediumblue: "rgb(0, 0, 205)", mediumorchid: "rgb(186, 85, 211)", mediumpurple: "rgb(147, 112, 219)", mediumseagreen: "rgb(60, 179, 113)", mediumslateblue: "rgb(123, 104, 238)", mediumspringgreen: "rgb(0, 250, 154)", mediumturquoise: "rgb(72, 209, 204)", mediumvioletred: "rgb(199, 21, 133)", midnightblue: "rgb(25, 25, 112)", mintcream: "rgb(245, 255, 250)", mistyrose: "rgb(255, 228, 225)", moccasin: "rgb(255, 228, 181)", navajowhite: "rgb(255, 222, 173)", navy: "rgb(0, 0, 128)", oldlace: "rgb(253, 245, 230)", olive: "rgb(128, 128, 0)", olivedrab: "rgb(107, 142, 35)", orange: "rgb(255, 165, 0)", orangered: "rgb(255, 69, 0)", orchid: "rgb(218, 112, 214)", palegoldenrod: "rgb(238, 232, 170)", palegreen: "rgb(152, 251, 152)", paleturquoise: "rgb(175, 238, 238)", palevioletred: "rgb(219, 112, 147)", papayawhip: "rgb(255, 239, 213)", peachpuff: "rgb(255, 218, 185)", peru: "rgb(205, 133, 63)", pink: "rgb(255, 192, 203)", plum: "rgb(221, 160, 221)", powderblue: "rgb(176, 224, 230)", purple: "rgb(128, 0, 128)", rebeccapurple: "rgb(102, 51, 153)", red: "rgb(255, 0, 0)", rosybrown: "rgb(188, 143, 143)", royalblue: "rgb(65, 105, 225)", saddlebrown: "rgb(139, 69, 19)", salmon: "rgb(250, 128, 114)", sandybrown: "rgb(244, 164, 96)", seagreen: "rgb(46, 139, 87)", seashell: "rgb(255, 245, 238)", sienna: "rgb(160, 82, 45)", silver: "rgb(192, 192, 192)", skyblue: "rgb(135, 206, 235)", slateblue: "rgb(106, 90, 205)", slategray: "rgb(112, 128, 144)", slategrey: "rgb(112, 128, 144)", snow: "rgb(255, 250, 250)", springgreen: "rgb(0, 255, 127)", steelblue: "rgb(70, 130, 180)", tan: "rgb(210, 180, 140)", teal: "rgb(0, 128, 128)", thistle: "rgb(216, 191, 216)", tomato: "rgb(255, 99, 71)", turquoise: "rgb(64, 224, 208)", violet: "rgb(238, 130, 238)", wheat: "rgb(245, 222, 179)", white: "rgb(255, 255, 255)", whitesmoke: "rgb(245, 245, 245)", yellow: "rgb(255, 255, 0)", yellowgreen: "rgb(154, 205, 50)", transparent: "rgba(0, 0, 0, 0)" }; + +function isColorName(name) { + return !!color_names[name]; +} + +function getColorByName(name) { + return color_names[name]; +} + +var ColorNames = { + isColorName: isColorName, + getColorByName: getColorByName +}; + +function HUEtoRGB(p, q, t) { + if (t < 0) t += 1; + if (t > 1) t -= 1; + if (t < 1 / 6) return p + (q - p) * 6 * t; + if (t < 1 / 2) return q; + if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; + return p; +} + +function HSLtoHSV(h, s, l) { + + if (arguments.length == 1) { + var _arguments$ = arguments[0], + h = _arguments$.h, + s = _arguments$.s, + l = _arguments$.l; + } + var rgb = HSLtoRGB(h, s, l); + + return RGBtoHSV(rgb.r, rgb.g, rgb.b); +} + +function HSLtoRGB(h, s, l) { + + if (arguments.length == 1) { + var _arguments$2 = arguments[0], + h = _arguments$2.h, + s = _arguments$2.s, + l = _arguments$2.l; + } + + var r, g, b; + + h /= 360; + s /= 100; + l /= 100; + + if (s == 0) { + r = g = b = l; // achromatic + } else { + var q = l < 0.5 ? l * (1 + s) : l + s - l * s; + var p = 2 * l - q; + r = HUEtoRGB(p, q, h + 1 / 3); + g = HUEtoRGB(p, q, h); + b = HUEtoRGB(p, q, h - 1 / 3); + } + + return { r: round(r * 255), g: round(g * 255), b: round(b * 255) }; +} + +var fromHSL = { + HUEtoRGB: HUEtoRGB, + HSLtoHSV: HSLtoHSV, + HSLtoRGB: HSLtoRGB +}; + +var classCallCheck = function (instance, Constructor) { + if (!(instance instanceof Constructor)) { + throw new TypeError("Cannot call a class as a function"); + } +}; + +var createClass = function () { + function defineProperties(target, props) { + for (var i = 0; i < props.length; i++) { + var descriptor = props[i]; + descriptor.enumerable = descriptor.enumerable || false; + descriptor.configurable = true; + if ("value" in descriptor) descriptor.writable = true; + Object.defineProperty(target, descriptor.key, descriptor); + } + } + + return function (Constructor, protoProps, staticProps) { + if (protoProps) defineProperties(Constructor.prototype, protoProps); + if (staticProps) defineProperties(Constructor, staticProps); + return Constructor; + }; +}(); + + + + + +var defineProperty = function (obj, key, value) { + if (key in obj) { + Object.defineProperty(obj, key, { + value: value, + enumerable: true, + configurable: true, + writable: true + }); + } else { + obj[key] = value; + } + + return obj; +}; + +var _extends = Object.assign || function (target) { + for (var i = 1; i < arguments.length; i++) { + var source = arguments[i]; + + for (var key in source) { + if (Object.prototype.hasOwnProperty.call(source, key)) { + target[key] = source[key]; + } + } + } + + return target; +}; + +var get = function get(object, property, receiver) { + if (object === null) object = Function.prototype; + var desc = Object.getOwnPropertyDescriptor(object, property); + + if (desc === undefined) { + var parent = Object.getPrototypeOf(object); + + if (parent === null) { + return undefined; + } else { + return get(parent, property, receiver); + } + } else if ("value" in desc) { + return desc.value; + } else { + var getter = desc.get; + + if (getter === undefined) { + return undefined; + } + + return getter.call(receiver); + } +}; + +var inherits = function (subClass, superClass) { + if (typeof superClass !== "function" && superClass !== null) { + throw new TypeError("Super expression must either be null or a function, not " + typeof superClass); + } + + subClass.prototype = Object.create(superClass && superClass.prototype, { + constructor: { + value: subClass, + enumerable: false, + writable: true, + configurable: true + } + }); + if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; +}; + + + + + + + + + + + +var possibleConstructorReturn = function (self, call) { + if (!self) { + throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); + } + + return call && (typeof call === "object" || typeof call === "function") ? call : self; +}; + + + + + +var slicedToArray = function () { + function sliceIterator(arr, i) { + var _arr = []; + var _n = true; + var _d = false; + var _e = undefined; + + try { + for (var _i = arr[Symbol.iterator](), _s; !(_n = (_s = _i.next()).done); _n = true) { + _arr.push(_s.value); + + if (i && _arr.length === i) break; + } + } catch (err) { + _d = true; + _e = err; + } finally { + try { + if (!_n && _i["return"]) _i["return"](); + } finally { + if (_d) throw _e; + } + } + + return _arr; + } + + return function (arr, i) { + if (Array.isArray(arr)) { + return arr; + } else if (Symbol.iterator in Object(arr)) { + return sliceIterator(arr, i); + } else { + throw new TypeError("Invalid attempt to destructure non-iterable instance"); + } + }; +}(); + + + + + + + + + + + +var toArray = function (arr) { + return Array.isArray(arr) ? arr : Array.from(arr); +}; + +var toConsumableArray = function (arr) { + if (Array.isArray(arr)) { + for (var i = 0, arr2 = Array(arr.length); i < arr.length; i++) arr2[i] = arr[i]; + + return arr2; + } else { + return Array.from(arr); + } +}; + +var color_regexp = /(#(?:[\da-f]{8})|#(?:[\da-f]{3}){1,2}|rgb\((?:\s*\d{1,3},\s*){2}\d{1,3}\s*\)|rgba\((?:\s*\d{1,3},\s*){3}\d*\.?\d+\s*\)|hsl\(\s*\d{1,3}(?:,\s*\d{1,3}%){2}\s*\)|hsla\(\s*\d{1,3}(?:,\s*\d{1,3}%){2},\s*\d*\.?\d+\s*\)|([\w_\-]+))/gi; +var color_split = ','; + +function matches(str) { + var matches = str.match(color_regexp); + var result = []; + + if (!matches) { + return result; + } + + for (var i = 0, len = matches.length; i < len; i++) { + + if (matches[i].indexOf('#') > -1 || matches[i].indexOf('rgb') > -1 || matches[i].indexOf('hsl') > -1) { + result.push({ color: matches[i] }); + } else { + var nameColor = ColorNames.getColorByName(matches[i]); + + if (nameColor) { + result.push({ color: matches[i], nameColor: nameColor }); + } + } + } + + var pos = { next: 0 }; + result.forEach(function (item) { + var startIndex = str.indexOf(item.color, pos.next); + + item.startIndex = startIndex; + item.endIndex = startIndex + item.color.length; + + pos.next = item.endIndex; + }); + + return result; +} + +function convertMatches(str) { + var m = matches(str); + + m.forEach(function (it, index) { + str = str.replace(it.color, '@' + index); + }); + + return { str: str, matches: m }; +} + +function convertMatchesArray(str) { + var splitStr = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ','; + + var ret = convertMatches(str); + return ret.str.split(splitStr).map(function (it, index) { + it = trim(it); + + if (ret.matches[index]) { + it = it.replace('@' + index, ret.matches[index].color); + } + + return it; + }); +} + +function reverseMatches(str, matches) { + matches.forEach(function (it, index) { + str = str.replace('@' + index, it.color); + }); + + return str; +} + +function trim(str) { + return str.replace(/^\s+|\s+$/g, ''); +} + +/** + * @method rgb + * + * parse string to rgb color + * + * color.parse("#FF0000") === { r : 255, g : 0, b : 0 } + * + * color.parse("rgb(255, 0, 0)") == { r : 255, g : 0, b :0 } + * color.parse(0xff0000) == { r : 255, g : 0, b : 0 } + * color.parse(0xff000000) == { r : 255, g : 0, b : 0, a: 0 } + * + * @param {String} str color string + * @returns {Object} rgb object + */ +function parse(str) { + if (typeof str == 'string') { + + if (ColorNames.isColorName(str)) { + str = ColorNames.getColorByName(str); + } + + if (str.indexOf("rgb(") > -1) { + var arr = str.replace("rgb(", "").replace(")", "").split(","); + + for (var i = 0, len = arr.length; i < len; i++) { + arr[i] = parseInt(trim(arr[i]), 10); + } + + var obj = { type: 'rgb', r: arr[0], g: arr[1], b: arr[2], a: 1 }; + + obj = Object.assign(obj, RGBtoHSL(obj)); + + return obj; + } else if (str.indexOf("rgba(") > -1) { + var arr = str.replace("rgba(", "").replace(")", "").split(","); + + for (var i = 0, len = arr.length; i < len; i++) { + + if (len - 1 == i) { + arr[i] = parseFloat(trim(arr[i])); + } else { + arr[i] = parseInt(trim(arr[i]), 10); + } + } + + var obj = { type: 'rgb', r: arr[0], g: arr[1], b: arr[2], a: arr[3] }; + + obj = Object.assign(obj, RGBtoHSL(obj)); + + return obj; + } else if (str.indexOf("hsl(") > -1) { + var arr = str.replace("hsl(", "").replace(")", "").split(","); + + for (var i = 0, len = arr.length; i < len; i++) { + arr[i] = parseFloat(trim(arr[i])); + } + + var obj = { type: 'hsl', h: arr[0], s: arr[1], l: arr[2], a: 1 }; + + obj = Object.assign(obj, HSLtoRGB(obj)); + + return obj; + } else if (str.indexOf("hsla(") > -1) { + var arr = str.replace("hsla(", "").replace(")", "").split(","); + + for (var i = 0, len = arr.length; i < len; i++) { + + if (len - 1 == i) { + arr[i] = parseFloat(trim(arr[i])); + } else { + arr[i] = parseInt(trim(arr[i]), 10); + } + } + + var obj = { type: 'hsl', h: arr[0], s: arr[1], l: arr[2], a: arr[3] }; + + obj = Object.assign(obj, HSLtoRGB(obj)); + + return obj; + } else if (str.indexOf("#") == 0) { + + str = str.replace("#", ""); + + var arr = []; + var a = 1; + if (str.length == 3) { + for (var i = 0, len = str.length; i < len; i++) { + var char = str.substr(i, 1); + arr.push(parseInt(char + char, 16)); + } + } else if (str.length === 8) { + for (var i = 0, len = str.length; i < len; i += 2) { + arr.push(parseInt(str.substr(i, 2), 16)); + } + + a = arr.pop() / 255; + } else { + for (var i = 0, len = str.length; i < len; i += 2) { + arr.push(parseInt(str.substr(i, 2), 16)); + } + } + + var obj = { type: 'hex', r: arr[0], g: arr[1], b: arr[2], a: a }; + + obj = Object.assign(obj, RGBtoHSL(obj)); + + return obj; + } + } else if (typeof str == 'number') { + if (0x000000 <= str && str <= 0xffffff) { + var r = (str & 0xff0000) >> 16; + var g = (str & 0x00ff00) >> 8; + var b = (str & 0x0000ff) >> 0; + + var obj = { type: 'hex', r: r, g: g, b: b, a: 1 }; + obj = Object.assign(obj, RGBtoHSL(obj)); + return obj; + } else if (0x00000000 <= str && str <= 0xffffffff) { + var _r = (str & 0xff000000) >> 24; + var _g = (str & 0x00ff0000) >> 16; + var _b = (str & 0x0000ff00) >> 8; + var _a = (str & 0x000000ff) / 255; + + var obj = { type: 'hex', r: _r, g: _g, b: _b, a: _a }; + obj = Object.assign(obj, RGBtoHSL(obj)); + + return obj; + } + } + + return str; +} + +function parseGradient(colors) { + if (typeof colors == 'string') { + colors = convertMatchesArray(colors); + } + + colors = colors.map(function (it) { + if (typeof it == 'string') { + var ret = convertMatches(it); + var arr = trim(ret.str).split(' '); + + if (arr[1]) { + if (arr[1].includes('%')) { + arr[1] = parseFloat(arr[1].replace(/%/, '')) / 100; + } else { + arr[1] = parseFloat(arr[1]); + } + } else { + arr[1] = '*'; + } + + arr[0] = reverseMatches(arr[0], ret.matches); + + return arr; + } else if (Array.isArray(it)) { + + if (!it[1]) { + it[1] = '*'; + } else if (typeof it[1] == 'string') { + if (it[1].includes('%')) { + it[1] = parseFloat(it[1].replace(/%/, '')) / 100; + } else { + it[1] = +it[1]; + } + } + + return [].concat(toConsumableArray(it)); + } + }); + + var count = colors.filter(function (it) { + return it[1] === '*'; + }).length; + + if (count > 0) { + var sum = colors.filter(function (it) { + return it[1] != '*' && it[1] != 1; + }).map(function (it) { + return it[1]; + }).reduce(function (total, cur) { + return total + cur; + }, 0); + + var dist = (1 - sum) / count; + colors.forEach(function (it, index) { + if (it[1] == '*' && index > 0) { + if (colors.length - 1 == index) { + // it[1] = 1 + } else { + it[1] = dist; + } + } + }); + } + + return colors; +} + +var parser = { + matches: matches, + convertMatches: convertMatches, + convertMatchesArray: convertMatchesArray, + reverseMatches: reverseMatches, + parse: parse, + parseGradient: parseGradient, + trim: trim, + color_regexp: color_regexp, + color_split: color_split +}; + +/** + * @method RGBtoHSV + * + * convert rgb to hsv + * + * color.RGBtoHSV(0, 0, 255) === { h : 240, s : 1, v : 1 } === '#FFFF00' + * + * @param {Number} R red color value + * @param {Number} G green color value + * @param {Number} B blue color value + * @return {Object} hsv color code + */ +function RGBtoHSV(r, g, b) { + + if (arguments.length == 1) { + var _arguments$ = arguments[0], + r = _arguments$.r, + g = _arguments$.g, + b = _arguments$.b; + } + + var R1 = r / 255; + var G1 = g / 255; + var B1 = b / 255; + + var MaxC = Math.max(R1, G1, B1); + var MinC = Math.min(R1, G1, B1); + + var DeltaC = MaxC - MinC; + + var H = 0; + + if (DeltaC == 0) { + H = 0; + } else if (MaxC == R1) { + H = 60 * ((G1 - B1) / DeltaC % 6); + } else if (MaxC == G1) { + H = 60 * ((B1 - R1) / DeltaC + 2); + } else if (MaxC == B1) { + H = 60 * ((R1 - G1) / DeltaC + 4); + } + + if (H < 0) { + H = 360 + H; + } + + var S = 0; + + if (MaxC == 0) S = 0;else S = DeltaC / MaxC; + + var V = MaxC; + + return { h: H, s: S, v: V }; +} + +function RGBtoCMYK(r, g, b) { + + if (arguments.length == 1) { + var _arguments$2 = arguments[0], + r = _arguments$2.r, + g = _arguments$2.g, + b = _arguments$2.b; + } + + var R1 = r / 255; + var G1 = g / 255; + var B1 = b / 255; + + var K = 1 - Math.max(R1, G1, B1); + var C = (1 - R1 - K) / (1 - K); + var M = (1 - G1 - K) / (1 - K); + var Y = (1 - B1 - K) / (1 - K); + + return { c: C, m: M, y: Y, k: K }; +} + +function RGBtoHSL(r, g, b) { + + if (arguments.length == 1) { + var _arguments$3 = arguments[0], + r = _arguments$3.r, + g = _arguments$3.g, + b = _arguments$3.b; + } + + r /= 255, g /= 255, b /= 255; + var max = Math.max(r, g, b), + min = Math.min(r, g, b); + var h, + s, + l = (max + min) / 2; + + if (max == min) { + h = s = 0; // achromatic + } else { + var d = max - min; + s = l > 0.5 ? d / (2 - max - min) : d / (max + min); + switch (max) { + case r: + h = (g - b) / d + (g < b ? 6 : 0);break; + case g: + h = (b - r) / d + 2;break; + case b: + h = (r - g) / d + 4;break; + } + h /= 6; + } + + return { h: round(h * 360), s: round(s * 100), l: round(l * 100) }; +} + +function c(r, g, b) { + + if (arguments.length == 1) { + var _arguments$4 = arguments[0], + r = _arguments$4.r, + g = _arguments$4.g, + b = _arguments$4.b; + } + return gray((r + g + b) / 3 > 90 ? 0 : 255); +} + +function gray(gray) { + return { r: gray, g: gray, b: gray }; +} + +function RGBtoSimpleGray(r, g, b) { + + if (arguments.length == 1) { + var _arguments$5 = arguments[0], + r = _arguments$5.r, + g = _arguments$5.g, + b = _arguments$5.b; + } + return gray(Math.ceil((r + g + b) / 3)); +} + +function RGBtoGray(r, g, b) { + + if (arguments.length == 1) { + var _arguments$6 = arguments[0], + r = _arguments$6.r, + g = _arguments$6.g, + b = _arguments$6.b; + } + return gray(RGBtoYCrCb(r, g, b).y); +} + +function brightness(r, g, b) { + return Math.ceil(r * 0.2126 + g * 0.7152 + b * 0.0722); +} + + + + + + + +function RGBtoYCrCb(r, g, b) { + + if (arguments.length == 1) { + var _arguments$7 = arguments[0], + r = _arguments$7.r, + g = _arguments$7.g, + b = _arguments$7.b; + } + var Y = brightness(r, g, b); + var Cb = 0.564 * (b - Y); + var Cr = 0.713 * (r - Y); + + return { y: Y, cr: Cr, cb: Cb }; +} + +function PivotRGB(n) { + var point = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0.04045; + + return (n > point ? Math.pow((n + 0.055) / 1.055, 2.4) : n / 12.92) * 100; +} + +function RGBtoXYZ(r, g, b) { + //sR, sG and sB (Standard RGB) input range = 0 ÷ 255 + //X, Y and Z output refer to a D65/2° standard illuminant. + if (arguments.length == 1) { + var _arguments$8 = arguments[0], + r = _arguments$8.r, + g = _arguments$8.g, + b = _arguments$8.b; + } + + var R = r / 255; + var G = g / 255; + var B = b / 255; + + R = PivotRGB(R); + G = PivotRGB(G); + B = PivotRGB(B); + + var x = R * 0.4124 + G * 0.3576 + B * 0.1805; + var y = R * 0.2126 + G * 0.7152 + B * 0.0722; + var z = R * 0.0193 + G * 0.1192 + B * 0.9505; + + return { x: x, y: y, z: z }; +} + +function RGBtoLAB(r, g, b) { + if (arguments.length == 1) { + var _arguments$9 = arguments[0], + r = _arguments$9.r, + g = _arguments$9.g, + b = _arguments$9.b; + } + return XYZtoLAB(RGBtoXYZ(r, g, b)); +} + +var fromRGB = { + RGBtoCMYK: RGBtoCMYK, + RGBtoGray: RGBtoGray, + RGBtoHSL: RGBtoHSL, + RGBtoHSV: RGBtoHSV, + RGBtoLAB: RGBtoLAB, + RGBtoSimpleGray: RGBtoSimpleGray, + RGBtoXYZ: RGBtoXYZ, + RGBtoYCrCb: RGBtoYCrCb, + c: c, + brightness: brightness, + gray: gray +}; + +function CMYKtoRGB(c, m, y, k) { + + if (arguments.length == 1) { + var _arguments$ = arguments[0], + c = _arguments$.c, + m = _arguments$.m, + y = _arguments$.y, + k = _arguments$.k; + } + + var R = 255 * (1 - c) * (1 - k); + var G = 255 * (1 - m) * (1 - k); + var B = 255 * (1 - y) * (1 - k); + + return { r: R, g: G, b: B }; +} + +var fromCMYK = { + CMYKtoRGB: CMYKtoRGB +}; + +function ReverseXyz(n) { + return Math.pow(n, 3) > 0.008856 ? Math.pow(n, 3) : (n - 16 / 116) / 7.787; +} + +function ReverseRGB(n) { + return n > 0.0031308 ? 1.055 * Math.pow(n, 1 / 2.4) - 0.055 : 12.92 * n; +} + +function XYZtoRGB(x, y, z) { + if (arguments.length == 1) { + var _arguments$ = arguments[0], + x = _arguments$.x, + y = _arguments$.y, + z = _arguments$.z; + } + //X, Y and Z input refer to a D65/2° standard illuminant. + //sR, sG and sB (standard RGB) output range = 0 ÷ 255 + + var X = x / 100.0; + var Y = y / 100.0; + var Z = z / 100.0; + + var R = X * 3.2406 + Y * -1.5372 + Z * -0.4986; + var G = X * -0.9689 + Y * 1.8758 + Z * 0.0415; + var B = X * 0.0557 + Y * -0.2040 + Z * 1.0570; + + R = ReverseRGB(R); + G = ReverseRGB(G); + B = ReverseRGB(B); + + var r = round(R * 255); + var g = round(G * 255); + var b = round(B * 255); + + return { r: r, g: g, b: b }; +} + +function LABtoXYZ(l, a, b) { + if (arguments.length == 1) { + var _arguments$2 = arguments[0], + l = _arguments$2.l, + a = _arguments$2.a, + b = _arguments$2.b; + } + //Reference-X, Y and Z refer to specific illuminants and observers. + //Common reference values are available below in this same page. + + var Y = (l + 16) / 116; + var X = a / 500 + Y; + var Z = Y - b / 200; + + Y = ReverseXyz(Y); + X = ReverseXyz(X); + Z = ReverseXyz(Z); + + var x = X * 95.047; + var y = Y * 100.000; + var z = Z * 108.883; + + return { x: x, y: y, z: z }; +} + + + + + +function LABtoRGB(l, a, b) { + if (arguments.length == 1) { + var _arguments$4 = arguments[0], + l = _arguments$4.l, + a = _arguments$4.a, + b = _arguments$4.b; + } + return XYZtoRGB(LABtoXYZ(l, a, b)); +} + +var fromLAB = { + XYZtoRGB: XYZtoRGB, + LABtoRGB: LABtoRGB, + LABtoXYZ: LABtoXYZ +}; + +/** + * @method HSVtoRGB + * + * convert hsv to rgb + * + * color.HSVtoRGB(0,0,1) === #FFFFF === { r : 255, g : 0, b : 0 } + * + * @param {Number} H hue color number (min : 0, max : 360) + * @param {Number} S Saturation number (min : 0, max : 1) + * @param {Number} V Value number (min : 0, max : 1 ) + * @returns {Object} + */ +function HSVtoRGB(h, s, v) { + + if (arguments.length == 1) { + var _arguments$ = arguments[0], + h = _arguments$.h, + s = _arguments$.s, + v = _arguments$.v; + } + + var H = h; + var S = s; + var V = v; + + if (H >= 360) { + H = 0; + } + + var C = S * V; + var X = C * (1 - Math.abs(H / 60 % 2 - 1)); + var m = V - C; + + var temp = []; + + if (0 <= H && H < 60) { + temp = [C, X, 0]; + } else if (60 <= H && H < 120) { + temp = [X, C, 0]; + } else if (120 <= H && H < 180) { + temp = [0, C, X]; + } else if (180 <= H && H < 240) { + temp = [0, X, C]; + } else if (240 <= H && H < 300) { + temp = [X, 0, C]; + } else if (300 <= H && H < 360) { + temp = [C, 0, X]; + } + + return { + r: round((temp[0] + m) * 255), + g: round((temp[1] + m) * 255), + b: round((temp[2] + m) * 255) + }; +} + +function HSVtoHSL(h, s, v) { + + if (arguments.length == 1) { + var _arguments$2 = arguments[0], + h = _arguments$2.h, + s = _arguments$2.s, + v = _arguments$2.v; + } + + var rgb = HSVtoRGB(h, s, v); + + return RGBtoHSL(rgb.r, rgb.g, rgb.b); +} + +var fromHSV = { + HSVtoHSL: HSVtoHSL, + HSVtoRGB: HSVtoRGB +}; + +function YCrCbtoRGB(y, cr, cb, bit) { + + if (arguments.length == 1) { + var _arguments$ = arguments[0], + y = _arguments$.y, + cr = _arguments$.cr, + cb = _arguments$.cb, + bit = _arguments$.bit; + + bit = bit || 0; + } + var R = y + 1.402 * (cr - bit); + var G = y - 0.344 * (cb - bit) - 0.714 * (cr - bit); + var B = y + 1.772 * (cb - bit); + + return { r: Math.ceil(R), g: Math.ceil(G), b: Math.ceil(B) }; +} + +var fromYCrCb = { + YCrCbtoRGB: YCrCbtoRGB +}; + +/** + * @deprecated + * + * instead of this, use blend function + * + * @param {*} startColor + * @param {*} endColor + * @param {*} t + */ +function interpolateRGB(startColor, endColor) { + var t = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 0.5; + var exportFormat = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 'hex'; + + var obj = { + r: round(startColor.r + (endColor.r - startColor.r) * t), + g: round(startColor.g + (endColor.g - startColor.g) * t), + b: round(startColor.b + (endColor.b - startColor.b) * t), + a: round(startColor.a + (endColor.a - startColor.a) * t, 100) + }; + + return format(obj, obj.a < 1 ? 'rgb' : exportFormat); +} + +function scale(scale) { + var count = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 5; + + if (!scale) return []; + + if (typeof scale === 'string') { + scale = convertMatchesArray(scale); + } + + scale = scale || []; + var len = scale.length; + + var colors = []; + for (var i = 0; i < len - 1; i++) { + for (var index = 0; index < count; index++) { + colors.push(blend(scale[i], scale[i + 1], index / count)); + } + } + return colors; +} + +function blend(startColor, endColor) { + var ratio = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 0.5; + var format$$1 = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 'hex'; + + var s = parse(startColor); + var e = parse(endColor); + + return interpolateRGB(s, e, ratio, format$$1); +} + +function mix(startcolor, endColor) { + var ratio = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 0.5; + var format$$1 = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 'hex'; + + return blend(startcolor, endColor, ratio, format$$1); +} + +/** + * + * @param {Color|String} c + */ +function contrast(c$$1) { + c$$1 = parse(c$$1); + return (Math.round(c$$1.r * 299) + Math.round(c$$1.g * 587) + Math.round(c$$1.b * 114)) / 1000; +} + +function contrastColor(c$$1) { + return contrast(c$$1) >= 128 ? 'black' : 'white'; +} + +function gradient(colors) { + var count = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 10; + + colors = parseGradient(colors); + + var newColors = []; + var maxCount = count - (colors.length - 1); + var allCount = maxCount; + + for (var i = 1, len = colors.length; i < len; i++) { + + var startColor = colors[i - 1][0]; + var endColor = colors[i][0]; + + // if it is second color + var rate = i == 1 ? colors[i][1] : colors[i][1] - colors[i - 1][1]; + + // if it is last color + var colorCount = i == colors.length - 1 ? allCount : Math.floor(rate * maxCount); + + newColors = newColors.concat(scale([startColor, endColor], colorCount), [endColor]); + + allCount -= colorCount; + } + return newColors; +} + +function scaleHSV(color) { + var target = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'h'; + var count = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 9; + var exportFormat = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 'rgb'; + var min = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : 0; + var max = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : 1; + var dist = arguments.length > 6 && arguments[6] !== undefined ? arguments[6] : 100; + + var colorObj = parse(color); + var hsv = RGBtoHSV(colorObj); + var unit = (max - min) * dist / count; + + var results = []; + for (var i = 1; i <= count; i++) { + hsv[target] = Math.abs((dist - unit * i) / dist); + results.push(format(HSVtoRGB(hsv), exportFormat)); + } + + return results; +} + +function scaleH(color) { + var count = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 9; + var exportFormat = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'rgb'; + var min = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 0; + var max = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : 360; + + return scaleHSV(color, 'h', count, exportFormat, min, max, 1); +} + +function scaleS(color) { + var count = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 9; + var exportFormat = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'rgb'; + var min = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 0; + var max = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : 1; + + return scaleHSV(color, 's', count, exportFormat, min, max, 100); +} + +function scaleV(color) { + var count = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 9; + var exportFormat = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'rgb'; + var min = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 0; + var max = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : 1; + + return scaleHSV(color, 'v', count, exportFormat, min, max, 100); +} + +/* predefined scale colors */ +scale.parula = function (count) { + return scale(['#352a87', '#0f5cdd', '#00b5a6', '#ffc337', '#fdff00'], count); +}; + +scale.jet = function (count) { + return scale(['#00008f', '#0020ff', '#00ffff', '#51ff77', '#fdff00', '#ff0000', '#800000'], count); +}; + +scale.hsv = function (count) { + return scale(['#ff0000', '#ffff00', '#00ff00', '#00ffff', '#0000ff', '#ff00ff', '#ff0000'], count); +}; + +scale.hot = function (count) { + return scale(['#0b0000', '#ff0000', '#ffff00', '#ffffff'], count); +}; +scale.pink = function (count) { + return scale(['#1e0000', '#bd7b7b', '#e7e5b2', '#ffffff'], count); +}; + +scale.bone = function (count) { + return scale(['#000000', '#4a4a68', '#a6c6c6', '#ffffff'], count); +}; + +scale.copper = function (count) { + return scale(['#000000', '#3d2618', '#9d623e', '#ffa167', '#ffc77f'], count); +}; + +var mixin = { + interpolateRGB: interpolateRGB, + blend: blend, + mix: mix, + scale: scale, + contrast: contrast, + contrastColor: contrastColor, + gradient: gradient, + scaleHSV: scaleHSV, + scaleH: scaleH, + scaleS: scaleS, + scaleV: scaleV +}; + +function array_equals(v1, v2) { + if (v1.length !== v2.length) return false; + for (var i = 0, len = v1.length; i < len; ++i) { + if (v1[i] !== v2[i]) return false; + } + return true; +} + +function euclidean(v1, v2) { + var total = 0; + + for (var i = 0, len = v1.length; i < len; i++) { + total += Math.pow(v2[i] - v1[i], 2); + } + + return Math.sqrt(total); +} + +function manhattan(v1, v2) { + var total = 0; + + for (var i = 0, len = v1.length; i < len; i++) { + total += Math.abs(v2[i] - v1[i]); + } + + return total; +} + +function max(v1, v2) { + var max = 0; + for (var i = 0, len = v1.length; i < len; i++) { + max = Math.max(max, Math.abs(v2[i] - v1[i])); + } + + return max; +} + +var distances = { + euclidean: euclidean, + manhattan: manhattan, + max: max +}; + +var create_random_number = { + linear: function linear(num, count) { + var centeroids = []; + var start = Math.round(Math.random() * num); + var dist = Math.floor(num / count); + + do { + + centeroids.push(start); + + start = (start + dist) % num; + } while (centeroids.length < count); + + return centeroids; + }, + + shuffle: function shuffle(num, count) { + var centeroids = []; + + while (centeroids.length < count) { + + var index = Math.round(Math.random() * num); + + if (centeroids.indexOf(index) == -1) { + centeroids.push(index); + } + } + + return centeroids; + } + +}; + +function randomCentroids(points, k) { + var method = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'linear'; + + + var centeroids = create_random_number[method](points.length, k); + + return centeroids.map(function (i) { + return points[i]; + }); + + // var centeroids = points.slice(0); + + // centeroids.sort(function () { + // return (Math.round(Math.random()) - 0.5); + // }) + + // return centeroids.slice(0, k); +} + +function closestCenteroid(point, centeroids, distance) { + var min = Infinity, + kIndex = 0; + + centeroids.forEach(function (center, i) { + var dist = distance(point, center); + + if (dist < min) { + min = dist; + kIndex = i; + } + }); + + return kIndex; +} + +function getCenteroid(assigned) { + + if (!assigned.length) return []; + + // initialize centeroid list + var centeroid = new Array(assigned[0].length); + for (var i = 0, len = centeroid.length; i < len; i++) { + centeroid[i] = 0; + } + + for (var index = 0, len = assigned.length; index < len; index++) { + var it = assigned[index]; + + var last = index + 1; + + for (var j = 0, jLen = it.length; j < jLen; j++) { + centeroid[j] += (it[j] - centeroid[j]) / last; + } + } + + centeroid = centeroid.map(function (it) { + return Math.floor(it); + }); + + return centeroid; +} + +function unique_array(arrays) { + return arrays; + var set = {}; + var count = arrays.length; + var it = null; + while (count--) { + it = arrays[count]; + set[JSON.stringify(it)] = it; + } + + return Object.values(set); +} + +function splitK(k, points, centeroids, distance) { + var assignment = new Array(k); + + for (var i = 0; i < k; i++) { + assignment[i] = []; + } + + for (var idx = 0, pointLength = points.length; idx < pointLength; idx++) { + var point = points[idx]; + var index = closestCenteroid(point, centeroids, distance); + assignment[index].push(point); + } + + return assignment; +} + +function setNewCenteroid(k, points, assignment, centeroids, movement, randomFunction) { + + for (var i = 0; i < k; i++) { + var assigned = assignment[i]; + + var centeroid = centeroids[i]; + var newCenteroid = new Array(centeroid.length); + + if (assigned.length > 0) { + newCenteroid = getCenteroid(assigned); + } else { + var idx = Math.floor(randomFunction() * points.length); + newCenteroid = points[idx]; + } + + if (array_equals(newCenteroid, centeroid)) { + movement = false; + } else { + movement = true; + } + + centeroids[i] = newCenteroid; + } + + return movement; +} + +function kmeans(points, k, distanceFunction) { + var period = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 10; + var initialRandom = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : 'linear'; + + points = unique_array(points); + + k = k || Math.max(2, Math.ceil(Math.sqrt(points.length / 2))); + + var distance = distanceFunction || 'euclidean'; + if (typeof distance == 'string') { + distance = distances[distance]; + } + + var rng_seed = 0; + var random = function random() { + rng_seed = (rng_seed * 9301 + 49297) % 233280; + return rng_seed / 233280; + }; + + var centeroids = randomCentroids(points, k, initialRandom); + + var movement = true; + var iterations = 0; + while (movement) { + var assignment = splitK(k, points, centeroids, distance); + + movement = setNewCenteroid(k, points, assignment, centeroids, false, random); + + iterations++; + + if (iterations % period == 0) { + break; + } + } + + return centeroids; +} + +function each(len, callback) { + for (var i = 0; i < len; i += 4) { + callback(i); + } +} + +function pack(bitmap, callback) { + + each(bitmap.pixels.length, function (i) { + callback(bitmap.pixels, i); + }); +} + +var Canvas = { + create: function create(width, height) { + var canvas = document.createElement('canvas'); + canvas.width = width || 0; + canvas.height = height || 0; + + return canvas; + }, + drawPixels: function drawPixels(bitmap) { + var canvas = this.create(bitmap.width, bitmap.height); + + var context = canvas.getContext('2d'); + var imagedata = context.getImageData(0, 0, canvas.width, canvas.height); + + imagedata.data.set(bitmap.pixels); + + context.putImageData(imagedata, 0, 0); + + return canvas; + }, + createHistogram: function createHistogram(width, height, histogram, callback) { + var opt = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : { black: true, red: false, green: false, blue: false }; + + var canvas = this.create(width, height); + var context = canvas.getContext('2d'); + context.clearRect(0, 0, width, height); + context.fillStyle = "white"; + context.fillRect(0, 0, width, height); + context.globalAlpha = 0.7; + + var omit = { black: false }; + if (opt.black) { + omit.black = false; + } else { + omit.black = true; + } + if (opt.red) { + omit.red = false; + } else { + omit.red = true; + } + if (opt.green) { + omit.green = false; + } else { + omit.green = true; + } + if (opt.blue) { + omit.blue = false; + } else { + omit.blue = true; + } + + Object.keys(histogram).forEach(function (color) { + + if (!omit[color]) { + + var array = histogram[color]; + var ymax = Math.max.apply(Math, array); + var unitWith = width / array.length; + + context.fillStyle = color; + array.forEach(function (it, index) { + var currentHeight = height * (it / ymax); + var x = index * unitWith; + + context.fillRect(x, height - currentHeight, unitWith, currentHeight); + }); + } + }); + + if (typeof callback == 'function') callback(canvas); + }, + getHistogram: function getHistogram(bitmap) { + var black = new Array(256); + var red = new Array(256); + var green = new Array(256); + var blue = new Array(256); + for (var i = 0; i < 256; i++) { + black[i] = 0; + red[i] = 0; + green[i] = 0; + blue[i] = 0; + } + + pack(bitmap, function (pixels, i) { + // gray scale + var grayIndex = Math.round(Color$1.brightness(pixels[i], pixels[i + 1], pixels[i + 2])); + black[grayIndex]++; + + red[pixels[i]]++; + green[pixels[i + 1]]++; + blue[pixels[i + 2]]++; + }); + + return { black: black, red: red, green: green, blue: blue }; + }, + getBitmap: function getBitmap(bitmap, area) { + var canvas = this.drawPixels(bitmap); + + var context = canvas.getContext('2d'); + var pixels = context.getImageData(area.x || 0, area.y || 0, area.width || canvas.width, area.height || canvas.height).data; + + return { pixels: pixels, width: area.width, height: area.height }; + }, + putBitmap: function putBitmap(bitmap, subBitmap, area) { + + var canvas = this.drawPixels(bitmap); + var subCanvas = this.drawPixels(subBitmap); + + var context = canvas.getContext('2d'); + context.drawImage(subCanvas, area.x, area.y); + + bitmap.pixels = context.getImageData(0, 0, bitmap.width, bitmap.height).data; + + return bitmap; + } +}; + +var ImageLoader = function () { + function ImageLoader(url) { + var opt = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + classCallCheck(this, ImageLoader); + + this.isLoaded = false; + this.imageUrl = url; + this.opt = opt; + this.initialize(); + } + + createClass(ImageLoader, [{ + key: 'initialize', + value: function initialize() { + this.canvas = this.createCanvas(); + this.context = this.canvas.getContext('2d'); + } + }, { + key: 'createCanvas', + value: function createCanvas() { + return document.createElement('canvas'); + } + }, { + key: 'load', + value: function load(callback) { + this.loadImage(callback); + } + }, { + key: 'loadImage', + value: function loadImage(callback) { + var _this = this; + + var ctx = this.context; + this.newImage = new Image(); + var img = this.newImage; + img.onload = function () { + var ratio = img.height / img.width; + + if (_this.opt.canvasWidth && _this.opt.canvasHeight) { + _this.canvas.width = _this.opt.canvasWidth; + _this.canvas.height = _this.opt.canvasHeight; + } else { + _this.canvas.width = _this.opt.maxWidth ? _this.opt.maxWidth : img.width; + _this.canvas.height = _this.canvas.width * ratio; + } + + ctx.drawImage(img, 0, 0, img.width, img.height, 0, 0, _this.canvas.width, _this.canvas.height); + _this.isLoaded = true; + callback && callback(); + }; + + this.getImageUrl(function (url) { + img.src = url; + }); + } + }, { + key: 'load', + value: function load(callback) { + var _this2 = this; + + this.newImage = new Image(); + var img = this.newImage; + img.onload = function () { + _this2.isLoaded = true; + callback && callback(); + }; + + this.getImageUrl(function (url) { + img.src = url; + }); + } + }, { + key: 'getImageUrl', + value: function getImageUrl(callback) { + if (typeof this.imageUrl == 'string') { + return callback(this.imageUrl); + } else if (this.imageUrl instanceof Blob) { + var reader = new FileReader(); + + reader.onload = function (ev) { + callback(ev.target.result); + }; + + reader.readAsDataURL(this.imageUrl); + } + } + }, { + key: 'getRGBA', + value: function getRGBA(r, g, b, a) { + return [r, g, b, a]; + } + }, { + key: 'toArray', + value: function toArray$$1(filter, callback) { + var opt = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; + + var imagedata = this.context.getImageData(0, 0, this.canvas.width, this.canvas.height); + var width = imagedata.width; + var height = imagedata.height; + + var pixels = new Uint8ClampedArray(imagedata.data); + + var bitmap = { pixels: pixels, width: width, height: height }; + + if (!filter) { + filter = function () { + return function (bitmap, done) { + done(bitmap); + }; + }(); + } + + filter(bitmap, function (newBitmap) { + var tmpCanvas = Canvas.drawPixels(newBitmap); + + if (opt.returnTo == 'canvas') { + callback(tmpCanvas); + } else { + callback(tmpCanvas.toDataURL(opt.outputFormat || 'image/png')); + } + }, opt); + } + }, { + key: 'toHistogram', + value: function toHistogram(opt) { + var imagedata = this.context.getImageData(0, 0, this.canvas.width, this.canvas.height); + var width = imagedata.width; + var height = imagedata.height; + + var pixels = new Uint8ClampedArray(imagedata.data); + + var bitmap = { pixels: pixels, width: width, height: height }; + + return Canvas.getHistogram(bitmap); + } + }, { + key: 'toRGB', + value: function toRGB() { + var imagedata = this.context.getImageData(0, 0, this.canvas.width, this.canvas.height); + + var rgba = imagedata.data; + var results = []; + for (var i = 0, len = rgba.length; i < len; i += 4) { + results[results.length] = [rgba[i + 0], rgba[i + 1], rgba[i + 2], rgba[i + 3]]; + } + + return results; + } + }]); + return ImageLoader; +}(); + +var CONSTANT = { + identity: function identity() { + return [1, 0, 0, 0, 1, 0, 0, 0, 1]; + }, + stretching: function stretching(k) { + return [k, 0, 0, 0, 1, 0, 0, 0, 1]; + }, + squeezing: function squeezing(k) { + return [k, 0, 0, 0, 1 / k, 0, 0, 0, 1]; + }, + scale: function scale() { + var sx = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + var sy = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1; + + sx = sx || sx === 0 ? sx : 1; + sy = sy || sy === 0 ? sy : 1; + return [sx, 0, 0, 0, sy, 0, 0, 0, 1]; + }, + scaleX: function scaleX(sx) { + return this.scale(sx); + }, + scaleY: function scaleY(sy) { + return this.scale(1, sy); + }, + translate: function translate(tx, ty) { + return [1, 0, tx, 0, 1, ty, 0, 0, 1]; + }, + rotate: function rotate(angle) { + var r = this.radian(angle); + return [Math.cos(r), -Math.sin(r), 0, Math.sin(r), Math.cos(r), 0, 0, 0, 1]; + }, + rotate90: function rotate90() { + return [0, -1, 0, 1, 0, 0, 0, 0, 1]; + }, + rotate180: function rotate180() { + return [-1, 0, 0, 0, -1, 0, 0, 0, 1]; + }, + rotate270: function rotate270() { + return [0, 1, 0, -1, 0, 0, 0, 0, 1]; + }, + radian: function radian(degree) { + return degree * Math.PI / 180; + }, + skew: function skew(degreeX, degreeY) { + var radianX = this.radian(degreeX); + var radianY = this.radian(degreeY); + return [1, Math.tan(radianX), 0, Math.tan(radianY), 1, 0, 0, 0, 1]; + }, + skewX: function skewX(degreeX) { + var radianX = this.radian(degreeX); + + return [1, Math.tan(radianX), 0, 0, 1, 0, 0, 0, 1]; + }, + skewY: function skewY(degreeY) { + var radianY = this.radian(degreeY); + + return [1, 0, 0, Math.tan(radianY), 1, 0, 0, 0, 1]; + }, + shear1: function shear1(angle) { + return [1, -Math.tan(this.radian(angle) / 2), 0, 0, 1, 0, 0, 0, 1]; + }, + shear2: function shear2(angle) { + return [1, 0, 0, Math.sin(this.radian(angle)), 1, 0, 0, 0, 1]; + } +}; + +var Matrix = { + CONSTANT: CONSTANT, + + radian: function radian(angle) { + return CONSTANT.radian(angle); + }, + multiply: function multiply(A, C) { + // console.log(JSON.stringify(A), JSON.stringify(C)) + return [A[0] * C[0] + A[1] * C[1] + A[2] * C[2], A[3] * C[0] + A[4] * C[1] + A[5] * C[2], A[6] * C[0] + A[7] * C[1] + A[8] * C[2]]; + }, + identity: function identity(B) { + return this.multiply(CONSTANT.identity(), B); + }, + translate: function translate(x, y, B) { + return this.multiply(CONSTANT.translate(x, y), B); + }, + rotate: function rotate(angle, B) { + return this.multiply(CONSTANT.rotate(angle), B); + }, + shear1: function shear1(angle, B) { + return this.multiply(CONSTANT.shear1(angle), B); + }, + shear2: function shear2(angle, B) { + return this.multiply(CONSTANT.shear2(angle), B); + }, + rotateShear: function rotateShear(angle, B) { + + var arr = B; + + arr = this.shear1(angle, arr); + arr = this.shear2(angle, arr); + arr = this.shear1(angle, arr); + + return arr; + } +}; + +function crop() { + var startX = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; + var startY = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0; + var width = arguments[2]; + var height = arguments[3]; + + + var newBitmap = createBitmap(width * height * 4, width, height); + + return function (bitmap, done) { + for (var y = startY, realY = 0; y < height; y++, realY++) { + for (var x = startX, realX = 0; x < width; x++, realX++) { + newBitmap.pixels[realY * width * realX] = bitmap.pixels[y * width * x]; + } + } + + done(newBitmap); + }; +} + +// Image manupulate +function resize(dstWidth, dstHeight) { + return function (bitmap, done) { + var c = Canvas.drawPixels(bitmap); + var context = c.getContext('2d'); + + c.width = dstWidth; + c.height = dstHeight; + + done({ + pixels: new Uint8ClampedArray(context.getImageData(0, 0, dstWidth, dstHeight).data), + width: dstWidth, + height: dstHeight + }); + }; +} + +function flipV() { + return function (bitmap, done) { + var width = bitmap.width; + var height = bitmap.height; + var isCenter = height % 2 == 1 ? 1 : 0; + + var halfHeight = isCenter ? Math.floor(height / 2) : height / 2; + + for (var y = 0; y < halfHeight; y++) { + for (var x = 0; x < width; x++) { + + var startIndex = y * width + x << 2; + var endIndex = (height - 1 - y) * width + x << 2; + swapColor(bitmap.pixels, startIndex, endIndex); + } + } + + done(bitmap); + }; +} + +function flipH() { + return function (bitmap, done) { + var width = bitmap.width; + var height = bitmap.height; + var isCenter = width % 2 == 1 ? 1 : 0; + + var halfWidth = isCenter ? Math.floor(width / 2) : width / 2; + + for (var y = 0; y < height; y++) { + for (var x = 0; x < halfWidth; x++) { + + var startIndex = y * width + x << 2; + var endIndex = y * width + (width - 1 - x) << 2; + swapColor(bitmap.pixels, startIndex, endIndex); + } + } + + done(bitmap); + }; +} + +function rotateDegree(angle) { + var cx = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'center'; + var cy = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'center'; + + // const r = F.radian(angle) + + return function (bitmap, done) { + var opt = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; + + var newBitmap = createBitmap(bitmap.pixels.length, bitmap.width, bitmap.height); + var width = bitmap.width; + var height = bitmap.height; + + if (cx == 'center') { + cx = Math.floor(width / 2); + } + + if (cy == 'center') { + cy = Math.floor(height / 2); + } + + var translateMatrix = Matrix.CONSTANT.translate(-cx, -cy); + var translateMatrix2 = Matrix.CONSTANT.translate(cx, cy); + var shear1Matrix = Matrix.CONSTANT.shear1(angle); + var shear2Matrix = Matrix.CONSTANT.shear2(angle); + + packXY(function (pixels, i, x, y) { + // console.log(x, y, i) + var arr = Matrix.multiply(translateMatrix, [x, y, 1]); + + arr = Matrix.multiply(shear1Matrix, arr).map(Math.round); + arr = Matrix.multiply(shear2Matrix, arr).map(Math.round); + arr = Matrix.multiply(shear1Matrix, arr).map(Math.round); + arr = Matrix.multiply(translateMatrix2, arr); + + var _arr = arr, + _arr2 = slicedToArray(_arr, 2), + x1 = _arr2[0], + y1 = _arr2[1]; + + if (x1 < 0) return; + if (y1 < 0) return; + if (x1 > width - 1) return; + if (y1 > height - 1) return; + + var endIndex = y1 * width + x1 << 2; // bit 2 shift is * 4 + + fillPixelColor(pixels, endIndex, bitmap.pixels, i); + })(newBitmap, function () { + done(newBitmap); + }, opt); + }; +} + +function rotate() { + var degree = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; + + degree = parseParamNumber(degree); + degree = degree % 360; + return function (bitmap, done) { + var opt = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; + + + if (degree == 0) return bitmap; + + if (degree == 90 || degree == 270) { + var newBitmap = createBitmap(bitmap.pixels.length, bitmap.height, bitmap.width); + } else if (degree == 180) { + var newBitmap = createBitmap(bitmap.pixels.length, bitmap.width, bitmap.height); + } else { + return rotateDegree(degree)(bitmap, done, opt); + } + packXY(function (pixels, i, x, y) { + + if (degree == 90) { + var endIndex = x * newBitmap.width + (newBitmap.width - 1 - y) << 2; // << 2 is equals to (multiply)* 4 + } else if (degree == 270) { + var endIndex = (newBitmap.height - 1 - x) * newBitmap.width + y << 2; + } else if (degree == 180) { + var endIndex = (newBitmap.height - 1 - y) * newBitmap.width + (newBitmap.width - 1 - x) << 2; + } + + fillPixelColor(newBitmap.pixels, endIndex, bitmap.pixels, i); + })(bitmap, function () { + done(newBitmap); + }, opt); + }; +} + +function histogram$1() { + var type = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'gray'; + var points = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : []; + + var $realPoints = []; + + for (var i = 0; i < points.length - 1; i++) { + var sp = points[i]; + var ep = points[i + 1]; + + var distX = ep[0] - sp[0]; + var distY = ep[1] - sp[1]; + + var rate = distY / distX; + + for (var realIndex = 0, start = sp[0]; realIndex < distX; realIndex++, start++) { + $realPoints[start] = sp[1] + realIndex * rate; + } + } + + $realPoints[255] = 255; + + if (type === 'red') { + return pixel(function () { + $r = $realPoints[$r]; + }, {}, { $realPoints: $realPoints }); + } else if (type === 'green') { + return pixel(function () { + $g = $realPoints[$g]; + }, {}, { $realPoints: $realPoints }); + } else if (type === 'blue') { + return pixel(function () { + $b = $realPoints[$b]; + }, {}, { $realPoints: $realPoints }); + } else { + return pixel(function () { + + var l = Color.RGBtoYCrCb($r, $g, $b); + var c = Color.YCrCbtoRGB(clamp($realPoints[clamp(l.y)]), l.cr, l.cb, 0); + $r = c.r; + $g = c.g; + $b = c.b; + }, {}, { $realPoints: $realPoints }); + } +} + +var image$1 = { + crop: crop, + resize: resize, + flipH: flipH, + flipV: flipV, + rotate: rotate, + rotateDegree: rotateDegree, + histogram: histogram$1, + 'rotate-degree': rotateDegree +}; + +function bitonal(darkColor, lightColor) { + var threshold = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 100; + + var $darkColor = Color$1.parse(darkColor); + var $lightColor = Color$1.parse(lightColor); + var $threshold = threshold; + + return pixel('\n const thresholdColor = ( $r + $g + $b ) <= $threshold ? $darkColor : $lightColor\n\n $r = thresholdColor.r\n $g = thresholdColor.g \n $b = thresholdColor.b \n ', { + $threshold: $threshold + }, { + $darkColor: $darkColor, + $lightColor: $lightColor + }); +} + +/* + * @param {Number} amount -100..100 , value < 0 is darken, value > 0 is brighten + */ +function brightness$1() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + + amount = parseParamNumber(amount); + var $C = Math.floor(255 * (amount / 100)); + + return pixel('\n $r += $C \n $g += $C \n $b += $C \n ', { $C: $C }); +} + +function brownie() { + + var $matrix = [0.5997023498159715, 0.34553243048391263, -0.2708298674538042, 0, -0.037703249837783157, 0.8609577587992641, 0.15059552388459913, 0, 0.24113635128153335, -0.07441037908422492, 0.44972182064877153, 0, 0, 0, 0, 1]; + + return pixel('\n $r = $matrix[0] * $r + $matrix[1] * $g + $matrix[2] * $b + $matrix[3] * $a\n $g = $matrix[4] * $r + $matrix[5] * $g + $matrix[6] * $b + $matrix[7] * $a\n $b = $matrix[8] * $r + $matrix[9] * $g + $matrix[10] * $b + $matrix[11] * $a\n $a = $matrix[12] * $r + $matrix[13] * $g + $matrix[14] * $b + $matrix[15] * $a \n ', { + $matrix: $matrix + }); +} + +/** + * + * @param {Number} amount from 0 to 100 + */ +function clip() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; + + amount = parseParamNumber(amount); + var $C = Math.abs(amount) * 2.55; + + return pixel('\n\n $r = ($r > 255 - $C) ? 255 : 0\n $g = ($g > 255 - $C) ? 255 : 0\n $b = ($b > 255 - $C) ? 255 : 0\n\n ', { $C: $C }); +} + +/** + * + * @param {*} amount min = -128, max = 128 + */ +function contrast$1() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; + + amount = parseParamNumber(amount); + var $C = Math.max((128 + amount) / 128, 0); + + return pixel('\n $r *= $C\n $g *= $C\n $b *= $C\n ', { $C: $C }); +} + +function gamma() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + + var $C = parseParamNumber(amount); + return pixel('\n $r = Math.pow($r / 255, $C) * 255\n $g = Math.pow($g / 255, $C) * 255\n $b = Math.pow($b / 255, $C) * 255\n ', { $C: $C }); +} + +/** + * F.gradient('red', 'blue', 'yellow', 'white', 10) + * F.gradient('red, blue, yellow, white, 10') + */ +function gradient$1() { + // 전체 매개변수 기준으로 파싱 + // 색이 아닌 것 기준으로 scale 변수로 인식 + + var params = [].concat(Array.prototype.slice.call(arguments)); + + if (params.length === 1 && typeof params[0] === 'string') { + params = Color$1.convertMatchesArray(params[0]); + } + + params = params.map(function (arg) { + var res = Color$1.matches(arg); + + if (!res.length) { + return { type: 'scale', value: arg }; + } + + return { type: 'param', value: arg }; + }); + + var $scale = params.filter(function (it) { + return it.type == 'scale'; + })[0]; + $scale = $scale ? +$scale.value : 256; + + params = params.filter(function (it) { + return it.type == 'param'; + }).map(function (it) { + return it.value; + }).join(','); + + var $colors = Color$1.gradient(params, $scale).map(function (c) { + var _Color$parse = Color$1.parse(c), + r = _Color$parse.r, + g = _Color$parse.g, + b = _Color$parse.b, + a = _Color$parse.a; + + return { r: r, g: g, b: b, a: a }; + }); + + return pixel('\n const colorIndex = clamp(Math.ceil($r * 0.2126 + $g * 0.7152 + $b * 0.0722))\n const newColorIndex = clamp(Math.floor(colorIndex * ($scale / 256)))\n const color = $colors[newColorIndex]\n\n $r = color.r \n $g = color.g \n $b = color.b \n $a = clamp(Math.floor(color.a * 256))\n ', {}, { $colors: $colors, $scale: $scale }); +} + +function grayscale(amount) { + amount = parseParamNumber(amount); + var C = amount / 100; + + if (C > 1) C = 1; + + var $matrix = [0.2126 + 0.7874 * (1 - C), 0.7152 - 0.7152 * (1 - C), 0.0722 - 0.0722 * (1 - C), 0, 0.2126 - 0.2126 * (1 - C), 0.7152 + 0.2848 * (1 - C), 0.0722 - 0.0722 * (1 - C), 0, 0.2126 - 0.2126 * (1 - C), 0.7152 - 0.7152 * (1 - C), 0.0722 + 0.9278 * (1 - C), 0, 0, 0, 0, 1]; + + return pixel( /*javascript*/'\n $r = $matrix[0] * $r + $matrix[1] * $g + $matrix[2] * $b + $matrix[3] * $a\n $g = $matrix[4] * $r + $matrix[5] * $g + $matrix[6] * $b + $matrix[7] * $a\n $b = $matrix[8] * $r + $matrix[9] * $g + $matrix[10] * $b + $matrix[11] * $a\n $a = $matrix[12] * $r + $matrix[13] * $g + $matrix[14] * $b + $matrix[15] * $a\n ', { + $matrix: $matrix + }); +} + +/* + * @param {Number} amount 0..360 + */ +function hue() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 360; + + var $C = parseParamNumber(amount); + return pixel('\n var hsv = Color.RGBtoHSV($r, $g, $b);\n\n // 0 ~ 360 \n var h = hsv.h;\n h += Math.abs($C)\n h = h % 360\n hsv.h = h\n\n var rgb = Color.HSVtoRGB(hsv);\n\n $r = rgb.r\n $g = rgb.g\n $b = rgb.b\n ', { + $C: $C + }); +} + +function invert() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 100; + + amount = parseParamNumber(amount); + var $C = amount / 100; + + return pixel('\n $r = (255 - $r) * $C\n $g = (255 - $g) * $C\n $b = (255 - $b) * $C\n ', { + $C: $C + }); +} + +function kodachrome() { + + var $matrix = [1.1285582396593525, -0.3967382283601348, -0.03992559172921793, 0, -0.16404339962244616, 1.0835251566291304, -0.05498805115633132, 0, -0.16786010706155763, -0.5603416277695248, 1.6014850761964943, 0, 0, 0, 0, 1]; + + return pixel('\n $r = $matrix[0] * $r + $matrix[1] * $g + $matrix[2] * $b + $matrix[3] * $a\n $g = $matrix[4] * $r + $matrix[5] * $g + $matrix[6] * $b + $matrix[7] * $a\n $b = $matrix[8] * $r + $matrix[9] * $g + $matrix[10] * $b + $matrix[11] * $a\n $a = $matrix[12] * $r + $matrix[13] * $g + $matrix[14] * $b + $matrix[15] * $a \n ', { + $matrix: $matrix + }); +} + +function matrix() { + var $a = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; + var $b = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0; + var $c = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 0; + var $d = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 0; + var $e = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : 0; + var $f = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : 0; + var $g = arguments.length > 6 && arguments[6] !== undefined ? arguments[6] : 0; + var $h = arguments.length > 7 && arguments[7] !== undefined ? arguments[7] : 0; + var $i = arguments.length > 8 && arguments[8] !== undefined ? arguments[8] : 0; + var $j = arguments.length > 9 && arguments[9] !== undefined ? arguments[9] : 0; + var $k = arguments.length > 10 && arguments[10] !== undefined ? arguments[10] : 0; + var $l = arguments.length > 11 && arguments[11] !== undefined ? arguments[11] : 0; + var $m = arguments.length > 12 && arguments[12] !== undefined ? arguments[12] : 0; + var $n = arguments.length > 13 && arguments[13] !== undefined ? arguments[13] : 0; + var $o = arguments.length > 14 && arguments[14] !== undefined ? arguments[14] : 0; + var $p = arguments.length > 15 && arguments[15] !== undefined ? arguments[15] : 0; + + + var $matrix = [$a, $b, $c, $d, $e, $f, $g, $h, $i, $j, $k, $l, $m, $n, $o, $p]; + + return pixel('\n $r = $matrix[0] * $r + $matrix[1] * $g + $matrix[2] * $b + $matrix[3] * $a\n $g = $matrix[4] * $r + $matrix[5] * $g + $matrix[6] * $b + $matrix[7] * $a\n $b = $matrix[8] * $r + $matrix[9] * $g + $matrix[10] * $b + $matrix[11] * $a\n $a = $matrix[12] * $r + $matrix[13] * $g + $matrix[14] * $b + $matrix[15] * $a \n ', { + $matrix: $matrix + }); +} + +/** + * + * @param {Number} amount 1..100 + */ +function noise() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + + var $C = parseParamNumber(amount); + return pixel('\n const C = Math.abs($C) * 5\n const min = -C\n const max = C \n const noiseValue = Math.round(min + (Math.random() * (max - min)))\n\n $r += noiseValue\n $g += noiseValue\n $b += noiseValue\n ', { + $C: $C + }); +} + +function opacity() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 100; + + amount = parseParamNumber(amount); + var $C = amount / 100; + + return pixel('\n $a *= $C \n ', { $C: $C }); +} + +function polaroid() { + + var $matrix = [1.438, -0.062, -0.062, 0, -0.122, 1.378, -0.122, 0, -0.016, -0.016, 1.483, 0, 0, 0, 0, 1]; + + return pixel('\n $r = $matrix[0] * $r + $matrix[1] * $g + $matrix[2] * $b + $matrix[3] * $a\n $g = $matrix[4] * $r + $matrix[5] * $g + $matrix[6] * $b + $matrix[7] * $a\n $b = $matrix[8] * $r + $matrix[9] * $g + $matrix[10] * $b + $matrix[11] * $a\n $a = $matrix[12] * $r + $matrix[13] * $g + $matrix[14] * $b + $matrix[15] * $a \n ', { + $matrix: $matrix + }); +} + +/* + * @param {Number} amount -100..100 + */ +function saturation() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 100; + + amount = parseParamNumber(amount); + var C = amount / 100; + var L = 1 - Math.abs(C); + + var $matrix = [L, 0, 0, 0, 0, L, 0, 0, 0, 0, L, 0, 0, 0, 0, L]; + + return pixel('\n $r = $matrix[0] * $r + $matrix[1] * $g + $matrix[2] * $b + $matrix[3] * $a\n $g = $matrix[4] * $r + $matrix[5] * $g + $matrix[6] * $b + $matrix[7] * $a\n $b = $matrix[8] * $r + $matrix[9] * $g + $matrix[10] * $b + $matrix[11] * $a\n $a = $matrix[12] * $r + $matrix[13] * $g + $matrix[14] * $b + $matrix[15] * $a \n ', { + $matrix: $matrix + }); +} + +/* + * @param {Number} amount 0..1 + */ +function sepia() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + + var C = parseParamNumber(amount); + if (C > 1) C = 1; + + var $matrix = [0.393 + 0.607 * (1 - C), 0.769 - 0.769 * (1 - C), 0.189 - 0.189 * (1 - C), 0, 0.349 - 0.349 * (1 - C), 0.686 + 0.314 * (1 - C), 0.168 - 0.168 * (1 - C), 0, 0.272 - 0.272 * (1 - C), 0.534 - 0.534 * (1 - C), 0.131 + 0.869 * (1 - C), 0, 0, 0, 0, 1]; + + return pixel('\n $r = $matrix[0] * $r + $matrix[1] * $g + $matrix[2] * $b + $matrix[3] * $a\n $g = $matrix[4] * $r + $matrix[5] * $g + $matrix[6] * $b + $matrix[7] * $a\n $b = $matrix[8] * $r + $matrix[9] * $g + $matrix[10] * $b + $matrix[11] * $a\n $a = $matrix[12] * $r + $matrix[13] * $g + $matrix[14] * $b + $matrix[15] * $a \n ', { + $matrix: $matrix + }); +} + +function shade() { + var redValue = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + var greenValue = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1; + var blueValue = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 1; + + var $redValue = parseParamNumber(redValue); + var $greenValue = parseParamNumber(greenValue); + var $blueValue = parseParamNumber(blueValue); + + return pixel('\n $r *= $redValue\n $g *= $greenValue\n $b *= $blueValue\n ', { + $redValue: $redValue, + $greenValue: $greenValue, + $blueValue: $blueValue + }); +} + +function shift() { + + var $matrix = [1.438, -0.062, -0.062, 0, -0.122, 1.378, -0.122, 0, -0.016, -0.016, 1.483, 0, 0, 0, 0, 1]; + + return pixel('\n $r = $matrix[0] * $r + $matrix[1] * $g + $matrix[2] * $b + $matrix[3] * $a\n $g = $matrix[4] * $r + $matrix[5] * $g + $matrix[6] * $b + $matrix[7] * $a\n $b = $matrix[8] * $r + $matrix[9] * $g + $matrix[10] * $b + $matrix[11] * $a\n $a = $matrix[12] * $r + $matrix[13] * $g + $matrix[14] * $b + $matrix[15] * $a \n ', { + $matrix: $matrix + }); +} + +/** + * change the relative darkness of (a part of an image) by overexposure to light. + * @param {*} r + * @param {*} g + * @param {*} b + */ +function solarize(redValue, greenValue, blueValue) { + var $redValue = parseParamNumber(redValue); + var $greenValue = parseParamNumber(greenValue); + var $blueValue = parseParamNumber(blueValue); + return pixel('\n $r = ($r < $redValue) ? 255 - $r: $r\n $g = ($g < $greenValue) ? 255 - $g: $g\n $b = ($b < $blueValue) ? 255 - $b: $b\n ', { + $redValue: $redValue, $greenValue: $greenValue, $blueValue: $blueValue + }); +} + +function technicolor() { + + var $matrix = [1.9125277891456083, -0.8545344976951645, -0.09155508482755585, 0, -0.3087833385928097, 1.7658908555458428, -0.10601743074722245, 0, -0.231103377548616, -0.7501899197440212, 1.847597816108189, 0, 0, 0, 0, 1]; + + return pixel('\n $r = $matrix[0] * $r + $matrix[1] * $g + $matrix[2] * $b + $matrix[3] * $a\n $g = $matrix[4] * $r + $matrix[5] * $g + $matrix[6] * $b + $matrix[7] * $a\n $b = $matrix[8] * $r + $matrix[9] * $g + $matrix[10] * $b + $matrix[11] * $a\n $a = $matrix[12] * $r + $matrix[13] * $g + $matrix[14] * $b + $matrix[15] * $a \n ', { + $matrix: $matrix + }); +} + +function thresholdColor() { + var scale = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 200; + var amount = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 100; + var hasColor = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : true; + + var $scale = parseParamNumber(scale); + amount = parseParamNumber(amount); + var $C = amount / 100; + var $hasColor = hasColor; + + return pixel('\n // refer to Color.brightness \n const v = ($C * Math.ceil($r * 0.2126 + $g * 0.7152 + $b * 0.0722) ) >= $scale ? 255 : 0;\n\n if ($hasColor) {\n\n if (v == 0) {\n $r = 0 \n $g = 0 \n $b = 0\n }\n \n } else {\n const value = Math.round(v)\n $r = value \n $g = value \n $b = value \n }\n \n ', { + $C: $C, $scale: $scale, $hasColor: $hasColor + }); +} + +/* + * @param {Number} amount 0..100 + */ +function threshold() { + var scale = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 200; + var amount = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 100; + + return thresholdColor(scale, amount, false); +} + +function tint () { + var redTint = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + var greenTint = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1; + var blueTint = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 1; + + var $redTint = parseParamNumber(redTint); + var $greenTint = parseParamNumber(greenTint); + var $blueTint = parseParamNumber(blueTint); + return pixel("\n\n $r += (255 - $r) * $redTint\n $g += (255 - $g) * $greenTint\n $b += (255 - $b) * $blueTint\n\n ", { + $redTint: $redTint, + $greenTint: $greenTint, + $blueTint: $blueTint + }); +} + +var pixel$1 = { + bitonal: bitonal, + brightness: brightness$1, + brownie: brownie, + clip: clip, + contrast: contrast$1, + gamma: gamma, + gradient: gradient$1, + grayscale: grayscale, + hue: hue, + invert: invert, + kodachrome: kodachrome, + matrix: matrix, + noise: noise, + opacity: opacity, + polaroid: polaroid, + saturation: saturation, + sepia: sepia, + shade: shade, + shift: shift, + solarize: solarize, + technicolor: technicolor, + threshold: threshold, + 'threshold-color': thresholdColor, + tint: tint +}; + +function blur () { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 3; + amount = parseParamNumber(amount); + + return convolution(createBlurMatrix(amount)); +} + +/* + * carve, mold, or stamp a design on (a surface) so that it stands out in relief. + * + * @param {Number} amount 0.0 .. 4.0 + */ +function emboss() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 4; + + amount = parseParamNumber(amount); + return convolution([amount * -2.0, -amount, 0.0, -amount, 1.0, amount, 0.0, amount, amount * 2.0]); +} + +function gaussianBlur() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 100; + + amount = parseParamNumber(amount); + var C = amount / 100; + + return convolution(weight([1, 2, 1, 2, 4, 2, 1, 2, 1], 1 / 16 * C)); +} + +function gaussianBlur5x() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 100; + + amount = parseParamNumber(amount); + var C = amount / 100; + return convolution(weight([1, 4, 6, 4, 1, 4, 16, 24, 16, 4, 6, 24, 36, 24, 6, 4, 16, 24, 16, 4, 1, 4, 6, 4, 1], 1 / 256 * C)); +} + +function grayscale2() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 100; + + amount = parseParamNumber(amount); + return convolution(weight([0.3, 0.3, 0.3, 0, 0, 0.59, 0.59, 0.59, 0, 0, 0.11, 0.11, 0.11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], amount / 100)); +} + +function identity() { + return convolution([0, 0, 0, 0, 1, 0, 0, 0, 0]); +} + +function kirschHorizontal() { + var count = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + + count = parseParamNumber(count); + return convolution([5, 5, 5, -3, 0, -3, -3, -3, -3]); +} + +function kirschVertical() { + var count = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + + count = parseParamNumber(count); + return convolution([5, -3, -3, 5, 0, -3, 5, -3, -3]); +} + +function laplacian() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 100; + + amount = parseParamNumber(amount); + return convolution(weight([-1, -1, -1, -1, 8, -1, -1, -1, -1], amount / 100)); +} + +function laplacian5x() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 100; + + amount = parseParamNumber(amount); + return convolution(weight([-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 24, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], amount / 100)); +} + +function motionBlur() { + return convolution(weight([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], 1 / 9)); +} + +function motionBlur2() { + return convolution(weight([1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1], 1 / 9)); +} + +function motionBlur3() { + return convolution(weight([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1], 1 / 9)); +} + +function negative() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 100; + + amount = parseParamNumber(amount); + return convolution(weight([-1, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1], amount / 100)); +} + +function sepia2() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 100; + + amount = parseParamNumber(amount); + return convolution(weight([0.393, 0.349, 0.272, 0, 0, 0.769, 0.686, 0.534, 0, 0, 0.189, 0.168, 0.131, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], amount / 100)); +} + +function sharpen() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 100; + + amount = parseParamNumber(amount); + return convolution(weight([0, -1, 0, -1, 5, -1, 0, -1, 0], amount / 100)); +} + +function sobelHorizontal() { + return convolution([-1, -2, -1, 0, 0, 0, 1, 2, 1]); +} + +function sobelVertical() { + return convolution([-1, 0, 1, -2, 0, 2, -1, 0, 1]); +} + +/* + +StackBlur - a fast almost Gaussian Blur For Canvas + +Version: 0.5 +Author: Mario Klingemann +Contact: mario@quasimondo.com +Website: http://www.quasimondo.com/StackBlurForCanvas +Twitter: @quasimondo + +In case you find this class useful - especially in commercial projects - +I am not totally unhappy for a small donation to my PayPal account +mario@quasimondo.de + +Or support me on flattr: +https://flattr.com/thing/72791/StackBlur-a-fast-almost-Gaussian-Blur-Effect-for-CanvasJavascript + +Copyright (c) 2010 Mario Klingemann + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. +*/ + +var mul_table = [512, 512, 456, 512, 328, 456, 335, 512, 405, 328, 271, 456, 388, 335, 292, 512, 454, 405, 364, 328, 298, 271, 496, 456, 420, 388, 360, 335, 312, 292, 273, 512, 482, 454, 428, 405, 383, 364, 345, 328, 312, 298, 284, 271, 259, 496, 475, 456, 437, 420, 404, 388, 374, 360, 347, 335, 323, 312, 302, 292, 282, 273, 265, 512, 497, 482, 468, 454, 441, 428, 417, 405, 394, 383, 373, 364, 354, 345, 337, 328, 320, 312, 305, 298, 291, 284, 278, 271, 265, 259, 507, 496, 485, 475, 465, 456, 446, 437, 428, 420, 412, 404, 396, 388, 381, 374, 367, 360, 354, 347, 341, 335, 329, 323, 318, 312, 307, 302, 297, 292, 287, 282, 278, 273, 269, 265, 261, 512, 505, 497, 489, 482, 475, 468, 461, 454, 447, 441, 435, 428, 422, 417, 411, 405, 399, 394, 389, 383, 378, 373, 368, 364, 359, 354, 350, 345, 341, 337, 332, 328, 324, 320, 316, 312, 309, 305, 301, 298, 294, 291, 287, 284, 281, 278, 274, 271, 268, 265, 262, 259, 257, 507, 501, 496, 491, 485, 480, 475, 470, 465, 460, 456, 451, 446, 442, 437, 433, 428, 424, 420, 416, 412, 408, 404, 400, 396, 392, 388, 385, 381, 377, 374, 370, 367, 363, 360, 357, 354, 350, 347, 344, 341, 338, 335, 332, 329, 326, 323, 320, 318, 315, 312, 310, 307, 304, 302, 299, 297, 294, 292, 289, 287, 285, 282, 280, 278, 275, 273, 271, 269, 267, 265, 263, 261, 259]; + +var shg_table = [9, 11, 12, 13, 13, 14, 14, 15, 15, 15, 15, 16, 16, 16, 16, 17, 17, 17, 17, 17, 17, 17, 18, 18, 18, 18, 18, 18, 18, 18, 18, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 19, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 20, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 21, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 22, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 23, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24, 24]; + +function BlurStack() { + this.r = 0; + this.g = 0; + this.b = 0; + this.a = 0; + this.next = null; +} + +function stackBlurImage(bitmap, radius, blurAlphaChannel) { + + if (blurAlphaChannel) return stackBlurCanvasRGBA(bitmap, 0, 0, radius);else return stackBlurCanvasRGB(bitmap, 0, 0, radius); +} + +function stackBlurCanvasRGBA(bitmap, top_x, top_y, radius) { + if (isNaN(radius) || radius < 1) return bitmap; + radius |= 0; + + var pixels = bitmap.pixels, + width = bitmap.width, + height = bitmap.height; + + var x, y, i, p, yp, yi, yw, r_sum, g_sum, b_sum, a_sum, r_out_sum, g_out_sum, b_out_sum, a_out_sum, r_in_sum, g_in_sum, b_in_sum, a_in_sum, pr, pg, pb, pa, rbs; + + var div = radius + radius + 1; + var widthMinus1 = width - 1; + var heightMinus1 = height - 1; + var radiusPlus1 = radius + 1; + var sumFactor = radiusPlus1 * (radiusPlus1 + 1) / 2; + + var stackStart = new BlurStack(); + var stack = stackStart; + for (i = 1; i < div; i++) { + stack = stack.next = new BlurStack(); + if (i == radiusPlus1) var stackEnd = stack; + } + stack.next = stackStart; + var stackIn = null; + var stackOut = null; + + yw = yi = 0; + + var mul_sum = mul_table[radius]; + var shg_sum = shg_table[radius]; + + for (y = 0; y < height; y++) { + r_in_sum = g_in_sum = b_in_sum = a_in_sum = r_sum = g_sum = b_sum = a_sum = 0; + + r_out_sum = radiusPlus1 * (pr = pixels[yi]); + g_out_sum = radiusPlus1 * (pg = pixels[yi + 1]); + b_out_sum = radiusPlus1 * (pb = pixels[yi + 2]); + a_out_sum = radiusPlus1 * (pa = pixels[yi + 3]); + + r_sum += sumFactor * pr; + g_sum += sumFactor * pg; + b_sum += sumFactor * pb; + a_sum += sumFactor * pa; + + stack = stackStart; + + for (i = 0; i < radiusPlus1; i++) { + stack.r = pr; + stack.g = pg; + stack.b = pb; + stack.a = pa; + stack = stack.next; + } + + for (i = 1; i < radiusPlus1; i++) { + p = yi + ((widthMinus1 < i ? widthMinus1 : i) << 2); + r_sum += (stack.r = pr = pixels[p]) * (rbs = radiusPlus1 - i); + g_sum += (stack.g = pg = pixels[p + 1]) * rbs; + b_sum += (stack.b = pb = pixels[p + 2]) * rbs; + a_sum += (stack.a = pa = pixels[p + 3]) * rbs; + + r_in_sum += pr; + g_in_sum += pg; + b_in_sum += pb; + a_in_sum += pa; + + stack = stack.next; + } + + stackIn = stackStart; + stackOut = stackEnd; + for (x = 0; x < width; x++) { + pixels[yi + 3] = pa = a_sum * mul_sum >> shg_sum; + if (pa != 0) { + pa = 255 / pa; + pixels[yi] = (r_sum * mul_sum >> shg_sum) * pa; + pixels[yi + 1] = (g_sum * mul_sum >> shg_sum) * pa; + pixels[yi + 2] = (b_sum * mul_sum >> shg_sum) * pa; + } else { + pixels[yi] = pixels[yi + 1] = pixels[yi + 2] = 0; + } + + r_sum -= r_out_sum; + g_sum -= g_out_sum; + b_sum -= b_out_sum; + a_sum -= a_out_sum; + + r_out_sum -= stackIn.r; + g_out_sum -= stackIn.g; + b_out_sum -= stackIn.b; + a_out_sum -= stackIn.a; + + p = yw + ((p = x + radius + 1) < widthMinus1 ? p : widthMinus1) << 2; + + r_in_sum += stackIn.r = pixels[p]; + g_in_sum += stackIn.g = pixels[p + 1]; + b_in_sum += stackIn.b = pixels[p + 2]; + a_in_sum += stackIn.a = pixels[p + 3]; + + r_sum += r_in_sum; + g_sum += g_in_sum; + b_sum += b_in_sum; + a_sum += a_in_sum; + + stackIn = stackIn.next; + + r_out_sum += pr = stackOut.r; + g_out_sum += pg = stackOut.g; + b_out_sum += pb = stackOut.b; + a_out_sum += pa = stackOut.a; + + r_in_sum -= pr; + g_in_sum -= pg; + b_in_sum -= pb; + a_in_sum -= pa; + + stackOut = stackOut.next; + + yi += 4; + } + yw += width; + } + + for (x = 0; x < width; x++) { + g_in_sum = b_in_sum = a_in_sum = r_in_sum = g_sum = b_sum = a_sum = r_sum = 0; + + yi = x << 2; + r_out_sum = radiusPlus1 * (pr = pixels[yi]); + g_out_sum = radiusPlus1 * (pg = pixels[yi + 1]); + b_out_sum = radiusPlus1 * (pb = pixels[yi + 2]); + a_out_sum = radiusPlus1 * (pa = pixels[yi + 3]); + + r_sum += sumFactor * pr; + g_sum += sumFactor * pg; + b_sum += sumFactor * pb; + a_sum += sumFactor * pa; + + stack = stackStart; + + for (i = 0; i < radiusPlus1; i++) { + stack.r = pr; + stack.g = pg; + stack.b = pb; + stack.a = pa; + stack = stack.next; + } + + yp = width; + + for (i = 1; i <= radius; i++) { + yi = yp + x << 2; + + r_sum += (stack.r = pr = pixels[yi]) * (rbs = radiusPlus1 - i); + g_sum += (stack.g = pg = pixels[yi + 1]) * rbs; + b_sum += (stack.b = pb = pixels[yi + 2]) * rbs; + a_sum += (stack.a = pa = pixels[yi + 3]) * rbs; + + r_in_sum += pr; + g_in_sum += pg; + b_in_sum += pb; + a_in_sum += pa; + + stack = stack.next; + + if (i < heightMinus1) { + yp += width; + } + } + + yi = x; + stackIn = stackStart; + stackOut = stackEnd; + for (y = 0; y < height; y++) { + p = yi << 2; + pixels[p + 3] = pa = a_sum * mul_sum >> shg_sum; + if (pa > 0) { + pa = 255 / pa; + pixels[p] = (r_sum * mul_sum >> shg_sum) * pa; + pixels[p + 1] = (g_sum * mul_sum >> shg_sum) * pa; + pixels[p + 2] = (b_sum * mul_sum >> shg_sum) * pa; + } else { + pixels[p] = pixels[p + 1] = pixels[p + 2] = 0; + } + + r_sum -= r_out_sum; + g_sum -= g_out_sum; + b_sum -= b_out_sum; + a_sum -= a_out_sum; + + r_out_sum -= stackIn.r; + g_out_sum -= stackIn.g; + b_out_sum -= stackIn.b; + a_out_sum -= stackIn.a; + + p = x + ((p = y + radiusPlus1) < heightMinus1 ? p : heightMinus1) * width << 2; + + r_sum += r_in_sum += stackIn.r = pixels[p]; + g_sum += g_in_sum += stackIn.g = pixels[p + 1]; + b_sum += b_in_sum += stackIn.b = pixels[p + 2]; + a_sum += a_in_sum += stackIn.a = pixels[p + 3]; + + stackIn = stackIn.next; + + r_out_sum += pr = stackOut.r; + g_out_sum += pg = stackOut.g; + b_out_sum += pb = stackOut.b; + a_out_sum += pa = stackOut.a; + + r_in_sum -= pr; + g_in_sum -= pg; + b_in_sum -= pb; + a_in_sum -= pa; + + stackOut = stackOut.next; + + yi += width; + } + } + + return bitmap; +} + +function stackBlurCanvasRGBA(bitmap, top_x, top_y, radius) { + if (isNaN(radius) || radius < 1) return bitmap; + radius |= 0; + + var pixels = bitmap.pixels, + width = bitmap.width, + height = bitmap.height; + + var x, y, i, p, yp, yi, yw, r_sum, g_sum, b_sum, r_out_sum, g_out_sum, b_out_sum, r_in_sum, g_in_sum, b_in_sum, pr, pg, pb, rbs; + + var div = radius + radius + 1; + var widthMinus1 = width - 1; + var heightMinus1 = height - 1; + var radiusPlus1 = radius + 1; + var sumFactor = radiusPlus1 * (radiusPlus1 + 1) / 2; + + var stackStart = new BlurStack(); + var stack = stackStart; + for (i = 1; i < div; i++) { + stack = stack.next = new BlurStack(); + if (i == radiusPlus1) var stackEnd = stack; + } + stack.next = stackStart; + var stackIn = null; + var stackOut = null; + + yw = yi = 0; + + var mul_sum = mul_table[radius]; + var shg_sum = shg_table[radius]; + + for (y = 0; y < height; y++) { + r_in_sum = g_in_sum = b_in_sum = r_sum = g_sum = b_sum = 0; + + r_out_sum = radiusPlus1 * (pr = pixels[yi]); + g_out_sum = radiusPlus1 * (pg = pixels[yi + 1]); + b_out_sum = radiusPlus1 * (pb = pixels[yi + 2]); + + r_sum += sumFactor * pr; + g_sum += sumFactor * pg; + b_sum += sumFactor * pb; + + stack = stackStart; + + for (i = 0; i < radiusPlus1; i++) { + stack.r = pr; + stack.g = pg; + stack.b = pb; + stack = stack.next; + } + + for (i = 1; i < radiusPlus1; i++) { + p = yi + ((widthMinus1 < i ? widthMinus1 : i) << 2); + r_sum += (stack.r = pr = pixels[p]) * (rbs = radiusPlus1 - i); + g_sum += (stack.g = pg = pixels[p + 1]) * rbs; + b_sum += (stack.b = pb = pixels[p + 2]) * rbs; + + r_in_sum += pr; + g_in_sum += pg; + b_in_sum += pb; + + stack = stack.next; + } + + stackIn = stackStart; + stackOut = stackEnd; + for (x = 0; x < width; x++) { + pixels[yi] = r_sum * mul_sum >> shg_sum; + pixels[yi + 1] = g_sum * mul_sum >> shg_sum; + pixels[yi + 2] = b_sum * mul_sum >> shg_sum; + + r_sum -= r_out_sum; + g_sum -= g_out_sum; + b_sum -= b_out_sum; + + r_out_sum -= stackIn.r; + g_out_sum -= stackIn.g; + b_out_sum -= stackIn.b; + + p = yw + ((p = x + radius + 1) < widthMinus1 ? p : widthMinus1) << 2; + + r_in_sum += stackIn.r = pixels[p]; + g_in_sum += stackIn.g = pixels[p + 1]; + b_in_sum += stackIn.b = pixels[p + 2]; + + r_sum += r_in_sum; + g_sum += g_in_sum; + b_sum += b_in_sum; + + stackIn = stackIn.next; + + r_out_sum += pr = stackOut.r; + g_out_sum += pg = stackOut.g; + b_out_sum += pb = stackOut.b; + + r_in_sum -= pr; + g_in_sum -= pg; + b_in_sum -= pb; + + stackOut = stackOut.next; + + yi += 4; + } + yw += width; + } + + for (x = 0; x < width; x++) { + g_in_sum = b_in_sum = r_in_sum = g_sum = b_sum = r_sum = 0; + + yi = x << 2; + r_out_sum = radiusPlus1 * (pr = pixels[yi]); + g_out_sum = radiusPlus1 * (pg = pixels[yi + 1]); + b_out_sum = radiusPlus1 * (pb = pixels[yi + 2]); + + r_sum += sumFactor * pr; + g_sum += sumFactor * pg; + b_sum += sumFactor * pb; + + stack = stackStart; + + for (i = 0; i < radiusPlus1; i++) { + stack.r = pr; + stack.g = pg; + stack.b = pb; + stack = stack.next; + } + + yp = width; + + for (i = 1; i <= radius; i++) { + yi = yp + x << 2; + + r_sum += (stack.r = pr = pixels[yi]) * (rbs = radiusPlus1 - i); + g_sum += (stack.g = pg = pixels[yi + 1]) * rbs; + b_sum += (stack.b = pb = pixels[yi + 2]) * rbs; + + r_in_sum += pr; + g_in_sum += pg; + b_in_sum += pb; + + stack = stack.next; + + if (i < heightMinus1) { + yp += width; + } + } + + yi = x; + stackIn = stackStart; + stackOut = stackEnd; + for (y = 0; y < height; y++) { + p = yi << 2; + pixels[p] = r_sum * mul_sum >> shg_sum; + pixels[p + 1] = g_sum * mul_sum >> shg_sum; + pixels[p + 2] = b_sum * mul_sum >> shg_sum; + + r_sum -= r_out_sum; + g_sum -= g_out_sum; + b_sum -= b_out_sum; + + r_out_sum -= stackIn.r; + g_out_sum -= stackIn.g; + b_out_sum -= stackIn.b; + + p = x + ((p = y + radiusPlus1) < heightMinus1 ? p : heightMinus1) * width << 2; + + r_sum += r_in_sum += stackIn.r = pixels[p]; + g_sum += g_in_sum += stackIn.g = pixels[p + 1]; + b_sum += b_in_sum += stackIn.b = pixels[p + 2]; + + stackIn = stackIn.next; + + r_out_sum += pr = stackOut.r; + g_out_sum += pg = stackOut.g; + b_out_sum += pb = stackOut.b; + + r_in_sum -= pr; + g_in_sum -= pg; + b_in_sum -= pb; + + stackOut = stackOut.next; + + yi += width; + } + } + + return bitmap; +} + +function stackBlur () { + var radius = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 10; + var hasAlphaChannel = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true; + + radius = parseParamNumber(radius); + + return function (bitmap, done) { + var newBitmap = stackBlurImage(bitmap, radius, hasAlphaChannel); + + done(newBitmap); + }; +} + +function transparency() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 100; + + amount = parseParamNumber(amount); + return convolution(weight([1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0.3, 0, 0, 0, 0, 0, 1], amount / 100)); +} + +function unsharpMasking() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 256; + + amount = parseParamNumber(amount); + return convolution(weight([1, 4, 6, 4, 1, 4, 16, 24, 16, 4, 6, 24, -476, 24, 6, 4, 16, 24, 16, 4, 1, 4, 6, 4, 1], -1 / amount)); +} + +var matrix$1 = { + blur: blur, + emboss: emboss, + gaussianBlur: gaussianBlur, + 'gaussian-blur': gaussianBlur, + gaussianBlur5x: gaussianBlur5x, + 'gaussian-blur-5x': gaussianBlur5x, + grayscale2: grayscale2, + normal: identity, + kirschHorizontal: kirschHorizontal, + 'kirsch-horizontal': kirschHorizontal, + kirschVertical: kirschVertical, + 'kirsch-vertical': kirschVertical, + laplacian: laplacian, + laplacian5x: laplacian5x, + 'laplacian-5x': laplacian5x, + motionBlur: motionBlur, + 'motion-blur': motionBlur, + motionBlur2: motionBlur2, + 'motion-blur-2': motionBlur2, + motionBlur3: motionBlur3, + 'motion-blur-3': motionBlur3, + negative: negative, + sepia2: sepia2, + sharpen: sharpen, + sobelHorizontal: sobelHorizontal, + 'sobel-horizontal': sobelHorizontal, + sobelVertical: sobelVertical, + 'sobel-vertical': sobelVertical, + stackBlur: stackBlur, + 'stack-blur': stackBlur, + transparency: transparency, + unsharpMasking: unsharpMasking, + 'unsharp-masking': unsharpMasking +}; + +function kirsch() { + return filter$1('kirsch-horizontal kirsch-vertical'); +} + +function sobel() { + return filter$1('sobel-horizontal sobel-vertical'); +} + +function vintage() { + return filter$1('brightness(15) saturation(-20) gamma(1.8)'); +} + +var multi$2 = { + kirsch: kirsch, + sobel: sobel, + vintage: vintage +}; + +var FilterList = _extends({}, image$1, pixel$1, matrix$1, multi$2); + +var _functions; + +var makeId = 0; + +var functions$1 = (_functions = { + partial: partial, + multi: multi$1, + merge: merge$1, + weight: weight, + repeat: repeat, + colorMatrix: colorMatrix, + each: each$1, + eachXY: eachXY, + createRandomCount: createRandomCount, + createRandRange: createRandRange, + createBitmap: createBitmap, + createBlurMatrix: createBlurMatrix, + pack: pack$1, + packXY: packXY, + pixel: pixel, + getBitmap: getBitmap, + putBitmap: putBitmap, + radian: radian, + convolution: convolution, + parseParamNumber: parseParamNumber, + filter: filter$1, + clamp: clamp$1, + fillColor: fillColor, + fillPixelColor: fillPixelColor +}, defineProperty(_functions, 'multi', multi$1), defineProperty(_functions, 'merge', merge$1), defineProperty(_functions, 'matches', matches$1), defineProperty(_functions, 'parseFilter', parseFilter), defineProperty(_functions, 'partial', partial), _functions); + +var LocalFilter = functions$1; + +function weight(arr) { + var num = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1; + + return arr.map(function (i) { + return i * num; + }); +} + +function repeat(value, num) { + var arr = new Array(num); + for (var i = 0; i < num; i++) { + arr[i] = value; + } + return arr; +} + +function colorMatrix(pixels, i, matrix) { + var r = pixels[i], + g = pixels[i + 1], + b = pixels[i + 2], + a = pixels[i + 3]; + + fillColor(pixels, i, matrix[0] * r + matrix[1] * g + matrix[2] * b + matrix[3] * a, matrix[4] * r + matrix[5] * g + matrix[6] * b + matrix[7] * a, matrix[8] * r + matrix[9] * g + matrix[10] * b + matrix[11] * a, matrix[12] * r + matrix[13] * g + matrix[14] * b + matrix[15] * a); +} + +function makeFilter$1(filter) { + + if (typeof filter == 'function') { + return filter; + } + + if (typeof filter == 'string') { + filter = [filter]; + } + + filter = filter.slice(0); + var filterName = filter.shift(); + + if (typeof filterName == 'function') { + return filterName; + } + + var params = filter; + + var filterFunction = FilterList[filterName] || LocalFilter[filterName]; + + if (!filterFunction) { + throw new Error(filterName + ' is not filter. please check filter name.'); + } + return filterFunction.apply(filterFunction, params); +} + +function forLoop(max) { + var index = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0; + var step = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 1; + var callback = arguments[3]; + var done = arguments[4]; + var functionDumpCount = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : 10000; + var frameTimer = arguments.length > 6 && arguments[6] !== undefined ? arguments[6] : 'full'; + var loopCount = arguments.length > 7 && arguments[7] !== undefined ? arguments[7] : 50; + + var runIndex = index; + var timer = function timer(callback) { + setTimeout(callback, 0); + }; + + if (frameTimer == 'requestAnimationFrame') { + timer = requestAnimationFrame; + functionDumpCount = 1000; + } + + if (frameTimer == 'full') { + /* only for loop */ + timer = null; + functionDumpCount = max; + } + + function makeFunction() { + var count = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 50; + + var arr = [].concat(toConsumableArray(Array(count))); + + var functionStrings = arr.map(function (countIndex) { + return 'cri = ri + i * s; if (cri >= mx) return {currentRunIndex: cri, i: null}; c(cri); i++;'; + }).join('\n'); + + var smallLoopFunction = new Function('ri', 'i', 's', 'mx', 'c', '\n let cri = ri;\n \n ' + functionStrings + '\n \n return {currentRunIndex: cri, i: i} \n '); + + return smallLoopFunction; + } + + function runCallback() { + + var smallLoopFunction = makeFunction(loopCount); // loop is call 20 callbacks at once + + var currentRunIndex = runIndex; + var ret = {}; + var i = 0; + while (i < functionDumpCount) { + ret = smallLoopFunction(runIndex, i, step, max, callback); + + if (ret.i == null) { + currentRunIndex = ret.currentRunIndex; + break; + } + + i = ret.i; + currentRunIndex = ret.currentRunIndex; + } + + nextCallback(currentRunIndex); + } + + function nextCallback(currentRunIndex) { + if (currentRunIndex) { + runIndex = currentRunIndex; + } else { + runIndex += step; + } + + if (runIndex >= max) { + done(); + return; + } + + if (timer) timer(runCallback);else runCallback(); + } + + runCallback(); +} + +function each$1(len, callback, done) { + var opt = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {}; + + + forLoop(len, 0, 4, function (i) { + callback(i, i >> 2 /* xyIndex */); + }, function () { + done(); + }, opt.functionDumpCount, opt.frameTimer, opt.loopCount); +} + +function eachXY(len, width, callback, done) { + var opt = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : {}; + + + forLoop(len, 0, 4, function (i) { + var xyIndex = i >> 2; + callback(i, xyIndex % width, Math.floor(xyIndex / width)); + }, function () { + done(); + }, opt.functionDumpCount, opt.frameTimer, opt.loopCount); +} + +function createRandRange(min, max, count) { + var result = []; + + for (var i = 1; i <= count; i++) { + var num = Math.random() * (max - min) + min; + var sign = Math.floor(Math.random() * 10) % 2 == 0 ? -1 : 1; + result.push(sign * num); + } + + result.sort(); + + var centerIndex = Math.floor(count >> 1); + var a = result[centerIndex]; + result[centerIndex] = result[0]; + result[0] = a; + + return result; +} + +function createRandomCount() { + return [3 * 3, 4 * 4, 5 * 5, 6 * 6, 7 * 7, 8 * 8, 9 * 9, 10 * 10].sort(function (a, b) { + return 0.5 - Math.random(); + })[0]; +} + +function createBitmap(length, width, height) { + return { pixels: new Uint8ClampedArray(length), width: width, height: height }; +} + +function putPixel(dstBitmap, srcBitmap, startX, startY) { + + var len = srcBitmap.pixels.length / 4; + var dstX = 0, + dstY = 0, + x = 0, + y = 0, + srcIndex = 0, + dstIndex = 0; + for (var i = 0; i < len; i++) { + x = i % srcBitmap.width, y = Math.floor(i / srcBitmap.width); + + dstX = startX + x; + dstY = startY + y; + + if (dstX > dstBitmap.width) continue; + if (dstY > dstBitmap.height) continue; + + srcIndex = y * srcBitmap.width + x << 2; + dstIndex = dstY * dstBitmap.width + dstX << 2; + + dstBitmap.pixels[dstIndex] = srcBitmap.pixels[srcIndex]; + dstBitmap.pixels[dstIndex + 1] = srcBitmap.pixels[srcIndex + 1]; + dstBitmap.pixels[dstIndex + 2] = srcBitmap.pixels[srcIndex + 2]; + dstBitmap.pixels[dstIndex + 3] = srcBitmap.pixels[srcIndex + 3]; + } +} + +function getPixel(srcBitmap, dstBitmap, startX, startY) { + var len = dstBitmap.pixels.length >> 2; + var srcX = 0, + srcY = 0, + x = 0, + y = 0, + srcIndex = 0, + dstIndex = 0; + for (var i = 0; i < len; i++) { + var x = i % dstBitmap.width, + y = Math.floor(i / dstBitmap.width); + + srcX = startX + x; + srcY = startY + y; + + if (srcX > srcBitmap.width) continue; + if (srcY > srcBitmap.height) continue; + + srcIndex = srcY * srcBitmap.width + srcX << 2; + dstIndex = y * dstBitmap.width + x << 2; + + dstBitmap.pixels[dstIndex] = srcBitmap.pixels[srcIndex]; + dstBitmap.pixels[dstIndex + 1] = srcBitmap.pixels[srcIndex + 1]; + dstBitmap.pixels[dstIndex + 2] = srcBitmap.pixels[srcIndex + 2]; + dstBitmap.pixels[dstIndex + 3] = srcBitmap.pixels[srcIndex + 3]; + } +} + +function cloneBitmap(bitmap) { + var padding = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0; + + + var width = bitmap.width + padding; + var height = bitmap.height + padding; + + var newBitmap = { pixels: new Uint8ClampedArray(width * height * 4), width: width, height: height }; + + return newBitmap; +} + +function getBitmap(bitmap, area) { + return Canvas.getBitmap(bitmap, area); +} + +function putBitmap(bitmap, subBitmap, area) { + return Canvas.putBitmap(bitmap, subBitmap, area); +} + +function parseParamNumber(param) { + if (typeof param === 'string') { + param = param.replace(/deg/, ''); + param = param.replace(/px/, ''); + } + return +param; +} + +var filter_regexp = /(([\w_\-]+)(\(([^\)]*)\))?)+/gi; +function pack$1(callback) { + return function (bitmap, done) { + each$1(bitmap.pixels.length, function (i, xyIndex) { + callback(bitmap.pixels, i, xyIndex, bitmap.pixels[i], bitmap.pixels[i + 1], bitmap.pixels[i + 2], bitmap.pixels[i + 3]); + }, function () { + done(bitmap); + }); + }; +} + +function makePrebuildUserFilterList(arr) { + + var codeString = arr.map(function (it) { + return ' \n ' + it.userFunction.$preContext + '\n\n ' + it.userFunction.$preCallbackString + '\n\n $r = clamp($r); $g = clamp($g); $b = clamp($b); $a = clamp($a);\n '; + }).join('\n\n'); + + var rootContextObject = { clamp: clamp$1, Color: Color$1 }; + arr.forEach(function (it) { + Object.assign(rootContextObject, it.userFunction.rootContextObject); + }); + + var rootContextDefine = 'const ' + Object.keys(rootContextObject).map(function (key) { + return ' ' + key + ' = $rc.' + key + ' '; + }).join(','); + + var FunctionCode = ' \n let $r = $p[$pi], $g = $p[$pi+1], $b = $p[$pi+2], $a = $p[$pi+3];\n \n ' + rootContextDefine + '\n\n ' + codeString + '\n \n $p[$pi] = $r; $p[$pi+1] = $g; $p[$pi+2] = $b; $p[$pi+3] = $a;\n '; + + var userFunction = new Function('$p', '$pi', '$rc', FunctionCode); + + return function ($pixels, $pixelIndex) { + userFunction($pixels, $pixelIndex, rootContextObject); + }; +} + +function makeUserFilterFunctionList(arr) { + var rootContextObject = {}; + var list = arr.map(function (it) { + var newKeys = []; + + Object.keys(it.context).forEach(function (key, i) { + newKeys[key] = 'n$' + makeId++ + key + '$'; + }); + + Object.keys(it.rootContext).forEach(function (key, i) { + newKeys[key] = 'r$' + makeId++ + key + '$'; + + rootContextObject[newKeys[key]] = it.rootContext[key]; + }); + + var preContext = Object.keys(it.context).filter(function (key) { + if (typeof it.context[key] === 'number' || typeof it.context[key] === 'string') { + return false; + } else if (Array.isArray(it.context[key])) { + if (typeof it.context[key][0] == 'number' || typeof it.context[key][0] == 'string') { + return false; + } + } + + return true; + }).map(function (key, i) { + return [newKeys[key], JSON.stringify(it.context[key])].join(' = '); + }); + + var preCallbackString = it.callback; + + if (typeof it.callback === 'function') { + preCallbackString = it.callback.toString().split("{"); + + preCallbackString.shift(); + preCallbackString = preCallbackString.join("{"); + preCallbackString = preCallbackString.split("}"); + preCallbackString.pop(); + preCallbackString = preCallbackString.join("}"); + } + + Object.keys(newKeys).forEach(function (key) { + var newKey = newKeys[key]; + + if (typeof it.context[key] === 'number' || typeof it.context[key] === 'string') { + preCallbackString = preCallbackString.replace(new RegExp("\\" + key, "g"), it.context[key]); + } else if (Array.isArray(it.context[key])) { + if (typeof it.context[key][0] == 'number' || typeof it.context[key][0] == 'string') { + it.context[key].forEach(function (item, index) { + preCallbackString = preCallbackString.replace(new RegExp("\\" + key + '\\[' + index + '\\]', "g"), item); + }); + } else { + preCallbackString = preCallbackString.replace(new RegExp("\\" + key, "g"), newKey); + } + } else { + preCallbackString = preCallbackString.replace(new RegExp("\\" + key, "g"), newKey); + } + }); + + return { preCallbackString: preCallbackString, preContext: preContext }; + }); + + var preContext = list.map(function (it, i) { + return it.preContext.length ? 'const ' + it.preContext + ';' : ""; + }).join('\n\n'); + + var preCallbackString = list.map(function (it) { + return it.preCallbackString; + }).join('\n\n'); + + var FunctionCode = ' \n let $r = $pixels[$pixelIndex], $g = $pixels[$pixelIndex+1], $b = $pixels[$pixelIndex+2], $a = $pixels[$pixelIndex+3];\n\n ' + preContext + '\n\n ' + preCallbackString + '\n \n $pixels[$pixelIndex] = $r\n $pixels[$pixelIndex+1] = $g \n $pixels[$pixelIndex+2] = $b \n $pixels[$pixelIndex+3] = $a \n '; + + var userFunction = new Function('$pixels', '$pixelIndex', '$clamp', '$Color', FunctionCode); + + userFunction.$preCallbackString = preCallbackString; + userFunction.$preContext = preContext; + userFunction.rootContextObject = rootContextObject; + + return userFunction; +} + +function makeUserFilterFunction(callback) { + var context = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + var rootContext = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; + + return makeUserFilterFunctionList([{ callback: callback, context: context, rootContext: rootContext }]); +} + +function pixel(callback) { + var context = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + var rootContext = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; + + var userFunction = makeUserFilterFunction(callback, context, rootContext); + + var returnCallback = function returnCallback(bitmap, done) {}; + + returnCallback.userFunction = userFunction; + + return returnCallback; +} + +var ColorListIndex = [0, 1, 2, 3]; + +function swapColor(pixels, startIndex, endIndex) { + + ColorListIndex.forEach(function (i) { + var temp = pixels[startIndex + i]; + pixels[startIndex + i] = pixels[endIndex + i]; + pixels[endIndex + i] = temp; + }); +} + +function packXY(callback) { + return function (bitmap, done) { + var opt = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; + + eachXY(bitmap.pixels.length, bitmap.width, function (i, x, y) { + callback(bitmap.pixels, i, x, y); + }, function () { + done(bitmap); + }, opt); + }; +} + +function radian(degree) { + return Matrix.CONSTANT.radian(degree); +} + +function createBlurMatrix() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 3; + + var count = Math.pow(amount, 2); + var value = 1 / count; + return repeat(value, count); +} + +function fillColor(pixels, i, r, g, b, a) { + if (arguments.length == 3) { + var _arguments$ = arguments[2], + r = _arguments$.r, + g = _arguments$.g, + b = _arguments$.b, + a = _arguments$.a; + } + + if (typeof r == 'number') { + pixels[i] = r; + } + if (typeof g == 'number') { + pixels[i + 1] = g; + } + if (typeof b == 'number') { + pixels[i + 2] = b; + } + if (typeof a == 'number') { + pixels[i + 3] = a; + } +} + +function fillPixelColor(targetPixels, targetIndex, sourcePixels, sourceIndex) { + fillColor(targetPixels, targetIndex, sourcePixels[sourceIndex], sourcePixels[sourceIndex + 1], sourcePixels[sourceIndex + 2], sourcePixels[sourceIndex + 3]); +} + + + +function createWeightTable(weights) { + var min = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0; + var max = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 255; + + var weightTable = []; + + weightTable = weights.map(function (w, i) { + return []; + }); + + weights.forEach(function (w, i) { + + if (w != 0) { + var data = weightTable[i]; + + for (var i = min; i <= max; i++) { + data[i] = w * i; + } + } + }); + + return weightTable; +} + +function createSubPixelWeightFunction(weights, weightTable, width, height, opaque) { + + var side = Math.round(Math.sqrt(weights.length)); + var halfSide = Math.floor(side / 2); + var alphaFac = opaque ? 1 : 0; + + var FunctionCode = 'let r = 0, g = 0, b = 0, a = 0, scy = 0, scx =0, si = 0; '; + var R = []; + var G = []; + var B = []; + var A = []; + weights.forEach(function (wt, index) { + var cy = Math.floor(index / side); + var cx = index % side; + var distY = cy - halfSide; + var distX = cx - halfSide; + + if (wt == 0) { + return; + } + + R.push('$t[' + index + '][$sp[(($sy + (' + distY + ')) * ' + width + ' + ($sx + (' + distX + '))) * 4]]'); + G.push('$t[' + index + '][$sp[(($sy + (' + distY + ')) * ' + width + ' + ($sx + (' + distX + '))) * 4 + 1]]'); + B.push('$t[' + index + '][$sp[(($sy + (' + distY + ')) * ' + width + ' + ($sx + (' + distX + '))) * 4 + 2]]'); + A.push('$t[' + index + '][$sp[(($sy + (' + distY + ')) * ' + width + ' + ($sx + (' + distX + '))) * 4 + 3]]'); + }); + + FunctionCode += 'r = ' + R.join(' + ') + '; g = ' + G.join(' + ') + '; b = ' + B.join(' + ') + '; a = ' + A.join(' + ') + ';'; + FunctionCode += '$dp[$di] = r; $dp[$di+1] = g;$dp[$di+2] = b;$dp[$di+3] = a + (' + alphaFac + ')*(255-a); '; + + // console.log(FunctionCode) + + var subPixelFunction = new Function('$dp', '$sp', '$di', '$sx', '$sy', '$t', FunctionCode); + + return function ($dp, $sp, $di, $sx, $sy) { + subPixelFunction($dp, $sp, $di, $sx, $sy, weightTable); + }; +} + +function convolution(weights) { + var opaque = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : true; + + var weightTable = createWeightTable(weights); + return function (bitmap, done) { + var side = Math.round(Math.sqrt(weights.length)); + var padding = side * 2; + + // 원본 크기를 늘림 + var sourceBitmap = cloneBitmap(bitmap, padding); + + // 원본 데이타 복사 + putPixel(sourceBitmap, bitmap, side, side); + + // 최종 아웃풋 + var newBitmap = createBitmap(sourceBitmap.pixels.length, sourceBitmap.width, sourceBitmap.height); + + // 마지막 원본 아웃풋 + var returnBitmap = createBitmap(bitmap.pixels.length, bitmap.width, bitmap.height); + + var subPixelWeightFunction = createSubPixelWeightFunction(weights, weightTable, sourceBitmap.width, sourceBitmap.height, opaque); + + var len = bitmap.pixels.length / 4; + for (var i = 0; i < len; i++) { + var xyIndex = i, + x = xyIndex % bitmap.width + side, + y = Math.floor(xyIndex / bitmap.width) + side; + + subPixelWeightFunction(newBitmap.pixels, sourceBitmap.pixels, (y * sourceBitmap.width + x) * 4, x, y); + } + + getPixel(newBitmap, returnBitmap, side, side); + done(returnBitmap); + }; +} + +function matches$1(str) { + var ret = Color$1.convertMatches(str); + var matches = ret.str.match(filter_regexp); + var result = []; + + if (!matches) { + return result; + } + + result = matches.map(function (it) { + return { filter: it, origin: Color$1.reverseMatches(it, ret.matches) }; + }); + + var pos = { next: 0 }; + result = result.map(function (item) { + + var startIndex = str.indexOf(item.origin, pos.next); + + item.startIndex = startIndex; + item.endIndex = startIndex + item.origin.length; + + item.arr = parseFilter(item.origin); + + pos.next = item.endIndex; + + return item; + }).filter(function (it) { + if (!it.arr.length) return false; + return true; + }); + + return result; +} + +/** + * Filter Parser + * + * F.parseFilter('blur(30)') == ['blue', '30'] + * F.parseFilter('gradient(white, black, 3)') == ['gradient', 'white', 'black', '3'] + * + * @param {String} filterString + */ +function parseFilter(filterString) { + + var ret = Color$1.convertMatches(filterString); + var matches = ret.str.match(filter_regexp); + + if (!matches[0]) { + return []; + } + + var arr = matches[0].split('('); + + var filterName = arr.shift(); + var filterParams = []; + + if (arr.length) { + filterParams = arr.shift().split(')')[0].split(',').map(function (f) { + return Color$1.reverseMatches(f, ret.matches); + }); + } + + var result = [filterName].concat(toConsumableArray(filterParams)).map(Color$1.trim); + + return result; +} + +function clamp$1(num) { + return Math.min(255, num); +} + +function filter$1(str) { + return merge$1(matches$1(str).map(function (it) { + return it.arr; + })); +} + +function makeGroupedFilter$1() { + var filters = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; + + var groupedFilter = []; + var group = []; + for (var i = 0, len = filters.length; i < len; i++) { + var f = filters[i]; + + if (f.userFunction) { + group.push(f); + } else { + if (group.length) { + groupedFilter.push([].concat(toConsumableArray(group))); + } + groupedFilter.push(f); + group = []; + } + } + + if (group.length) { + groupedFilter.push([].concat(toConsumableArray(group))); + } + + groupedFilter.forEach(function (filter, index) { + if (Array.isArray(filter)) { + groupedFilter[index] = function () { + var userFunction = makePrebuildUserFilterList(filter); + // console.log(userFunction) + return function (bitmap, done) { + + for (var i = 0, len = bitmap.pixels.length; i < len; i += 4) { + userFunction(bitmap.pixels, i); + } + + done(bitmap); + // forLoop(bitmap.pixels.length, 0, 4, function (i) { + // userFunction(bitmap.pixels, i) + // }, function () { + // done(bitmap) + // }) + }; + }(); + } + }); + + return groupedFilter; +} + +/** + * + * multiply filters + * + * ImageFilter.multi('blur', 'grayscale', 'sharpen', ['blur', 3], function (bitmap) { return bitmap }); + * + */ +function multi$1() { + for (var _len = arguments.length, filters = Array(_len), _key = 0; _key < _len; _key++) { + filters[_key] = arguments[_key]; + } + + filters = filters.map(function (filter) { + return makeFilter$1(filter); + }).filter(function (f) { + return f; + }); + + filters = makeGroupedFilter$1(filters); + + var max = filters.length; + + return function (bitmap, done) { + var opt = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; + + + var currentBitmap = bitmap; + var index = 0; + + function runFilter() { + filters[index].call(null, currentBitmap, function (nextBitmap) { + currentBitmap = nextBitmap; + + nextFilter(); + }, opt); + } + + function nextFilter() { + index++; + + if (index >= max) { + done(currentBitmap); + return; + } + + runFilter(); + } + + runFilter(); + }; +} + +function merge$1(filters) { + return multi$1.apply(undefined, toConsumableArray(filters)); +} + +/** + * apply filter into special area + * + * F.partial({x,y,width,height}, filter, filter, filter ) + * F.partial({x,y,width,height}, 'filter' ) + * + * @param {{x, y, width, height}} area + * @param {*} filters + */ +function partial(area) { + var allFilter = null; + + for (var _len2 = arguments.length, filters = Array(_len2 > 1 ? _len2 - 1 : 0), _key2 = 1; _key2 < _len2; _key2++) { + filters[_key2 - 1] = arguments[_key2]; + } + + if (filters.length == 1 && typeof filters[0] === 'string') { + allFilter = filter$1(filters[0]); + } else { + allFilter = merge$1(filters); + } + + return function (bitmap, done) { + var opt = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; + + allFilter(getBitmap(bitmap, area), function (newBitmap) { + done(putBitmap(bitmap, newBitmap, area)); + }, opt); + }; +} + +function parseParamNumber$1(param) { + if (typeof param === 'string') { + param = param.replace(/deg/, ''); + param = param.replace(/px/, ''); + } + return +param; +} + +function weight$1(arr) { + var num = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1; + + return arr.map(function (i) { + return i * num; + }); +} + +var SHADER_INDEX = 0; + +function convolutionString(count) { + + var width = Math.sqrt(count); + var half = Math.floor(width / 2); + + return [].concat(toConsumableArray(Array(count))).map(function (it, index) { + var y = Math.floor(index / width) - half; + var x = index % width - half; + + return 'texture(u_image, v_texCoord + onePixel * vec2(' + x + ', ' + y + ')) * u_kernel' + count + '[' + index + ']'; + }).join(' + \n'); +} + +function multi$3(str) { + return [].concat(Array.prototype.slice.call(arguments)); +} + +function convolution$1(arr) { + + return { + type: 'convolution', + length: arr.length, + content: arr + }; +} + +function makeShader(str, index) { + return '\n if (u_filterIndex == ' + index + '.0) {\n ' + str + '\n }\n '; +} + +function shader(str, options) { + return { + type: 'shader', + index: SHADER_INDEX, + options: options, + content: makeShader(str, SHADER_INDEX++) + }; +} + +function makeVertexShaderSource() { + return '#version 300 es \n\n in vec2 a_position;\n in vec2 a_texCoord; \n\n uniform vec2 u_resolution;\n uniform float u_flipY;\n\n out vec2 v_texCoord; \n\n void main() {\n vec2 zeroToOne = a_position / u_resolution;\n\n vec2 zeroToTwo = zeroToOne * 2.0;\n\n vec2 clipSpace = zeroToTwo - 1.0;\n\n gl_Position = vec4(clipSpace * vec2(1, u_flipY), 0, 1);\n\n v_texCoord = a_texCoord;\n\n }\n '; +} + +function makeConvolution(count) { + + return '\n \n if (u_kernelSelect == ' + count + '.0) {\n vec4 colorSum = ' + convolutionString(count) + '; \n\n outColor = vec4((colorSum / u_kernel' + count + 'Weight).rgb, 1);\n \n }\n '; +} + +function makeFragmentShaderSource(filterShaderList) { + + var filterContent = filterShaderList.filter(function (f) { + return f.type == 'shader'; + }).map(function (f) { + return f.content; + }).join('\n\n'); + + var weightTable = { '9': true }; + + filterShaderList.filter(function (f) { + return f.type == 'convolution'; + }).forEach(function (f) { + weightTable[f.length] = true; + }); + + var convolutionString = Object.keys(weightTable).map(function (len) { + return makeConvolution(+len); + }).join('\n'); + + return '#version 300 es\n\n precision highp int;\n precision mediump float;\n \n uniform sampler2D u_image;\n\n // 3 is 3x3 matrix kernel \n uniform float u_kernelSelect;\n uniform float u_filterIndex;\n\n uniform float u_kernel9[9];\n uniform float u_kernel9Weight;\n uniform float u_kernel25[25];\n uniform float u_kernel25Weight;\n uniform float u_kernel49[49];\n uniform float u_kernel49Weight;\n uniform float u_kernel81[81];\n uniform float u_kernel81Weight; \n\n in vec2 v_texCoord;\n \n out vec4 outColor;\n\n float random (vec2 st) {\n return fract(sin(dot(st.xy, vec2(12.9898,78.233)))* 43758.5453123);\n } \n\n // \n vec3 rgb2hsv(vec3 c)\n {\n vec4 K = vec4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);\n vec4 p = c.g < c.b ? vec4(c.bg, K.wz) : vec4(c.gb, K.xy);\n vec4 q = c.r < p.x ? vec4(p.xyw, c.r) : vec4(c.r, p.yzx);\n\n float d = q.x - min(q.w, q.y);\n float e = 1.0e-10;\n return vec3(abs(q.z + (q.w - q.y) / (6.0 * d + e)), d / (q.x + e), q.x);\n }\n\n vec3 hsv2rgb(vec3 c)\n {\n vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);\n vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);\n return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);\n }\n \n void main() {\n vec4 pixelColor = texture(u_image, v_texCoord);\n vec2 onePixel = vec2(1) / vec2(textureSize(u_image, 0)); \n\n ' + filterContent + '\n\n ' + convolutionString + '\n\n }'; +} + +function colorToVec4(color) { + color = [color.r / 255, color.g / 255, color.b / 255, color.a || 0].map(toFloatString); + return 'vec4(' + color + ')'; +} + +function toFloatString(number) { + if (number == Math.floor(number)) { + return number + '.0'; + } + + return number; +} + +function blur$1 () { + return convolution$1([1, 1, 1, 1, 1, 1, 1, 1, 1]); +} + +function normal () { + return convolution$1([0, 0, 0, 0, 1, 0, 0, 0, 0]); +} + +/* + * carve, mold, or stamp a design on (a surface) so that it stands out in relief. + * + * @param {Number} amount 0.0 .. 4.0 + */ +function emboss$1() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 4; + + amount = parseParamNumber$1(amount); + return convolution$1([amount * -2.0, -amount, 0.0, -amount, 1.0, amount, 0.0, amount, amount * 2.0]); +} + +/** + * + * @param {Number} amount 0..1 + */ +function gaussianBlur$1() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + + var C = parseParamNumber$1(amount) * (1 / 16); + + return convolution$1(weight$1([1, 2, 1, 2, 4, 2, 1, 2, 1], C)); +} + +function gaussianBlur5x$1() { + return convolution$1([1, 4, 6, 4, 1, 4, 16, 24, 16, 4, 6, 24, 36, 24, 6, 4, 16, 24, 16, 4, 1, 4, 6, 4, 1]); +} + +function grayscale2$1() { + return convolution$1([0.3, 0.3, 0.3, 0, 0, 0.59, 0.59, 0.59, 0, 0, 0.11, 0.11, 0.11, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); +} + +function kirschHorizontal$1() { + return convolution$1([5, 5, 5, -3, 0, -3, -3, -3, -3]); +} + +function kirschVertical$1() { + return convolution$1([5, -3, -3, 5, 0, -3, 5, -3, -3]); +} + +function laplacian$1() { + return convolution$1([-1, -1, -1, -1, 8, -1, -1, -1, -1]); +} + +function laplacian5x$1() { + return convolution$1([-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 24, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1]); +} + +function motionBlur$1() { + return convolution$1([1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]); +} + +function motionBlur2$1() { + return convolution$1([1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 1]); +} + +function motionBlur3$1() { + return convolution$1([1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 0, 0, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 0, 0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1]); +} + +function negative$1() { + return convolution$1([-1, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 1, 1, 1, 1, 1]); +} + +function sepia2$1() { + return convolution$1([0.393, 0.349, 0.272, 0, 0, 0.769, 0.686, 0.534, 0, 0, 0.189, 0.168, 0.131, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]); +} + +function sharpen$1() { + return convolution$1([0, -1, 0, -1, 5, -1, 0, -1, 0]); +} + +function sobelHorizontal$1() { + return convolution$1([-1, -2, -1, 0, 0, 0, 1, 2, 1]); +} + +function sobelVertical$1() { + return convolution$1([-1, 0, 1, -2, 0, 2, -1, 0, 1]); +} + +function transparency$1() { + return convolution$1([1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0.3, 0, 0, 0, 0, 0, 1]); +} + +function unsharpMasking$1() { + return convolution$1(weight$1([1, 4, 6, 4, 1, 4, 16, 24, 16, 4, 6, 24, -476, 24, 6, 4, 16, 24, 16, 4, 1, 4, 6, 4, 1], -1 / 256)); +} + +var matrix$2 = { + blur: blur$1, + normal: normal, + emboss: emboss$1, + gaussianBlur: gaussianBlur$1, + 'gaussian-blur': gaussianBlur$1, + gaussianBlur5x: gaussianBlur5x$1, + 'gaussian-blur-5x': gaussianBlur5x$1, + grayscale2: grayscale2$1, + kirschHorizontal: kirschHorizontal$1, + 'kirsch-horizontal': kirschHorizontal$1, + kirschVertical: kirschVertical$1, + 'kirsch-vertical': kirschVertical$1, + laplacian: laplacian$1, + laplacian5x: laplacian5x$1, + 'laplacian-5x': laplacian5x$1, + motionBlur: motionBlur$1, + 'motion-blur': motionBlur$1, + motionBlur2: motionBlur2$1, + 'motion-blur-2': motionBlur2$1, + motionBlur3: motionBlur3$1, + 'motion-blur-3': motionBlur3$1, + negative: negative$1, + sepia2: sepia2$1, + sharpen: sharpen$1, + sobelHorizontal: sobelHorizontal$1, + 'sobel-horizontal': sobelHorizontal$1, + sobelVertical: sobelVertical$1, + 'sobel-vertical': sobelVertical$1, + transparency: transparency$1, + unsharpMasking: unsharpMasking$1, + 'unsharp-masking': unsharpMasking$1 +}; + +function bitonal$1(darkColor, lightColor) { + var threshold = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 0.5; + + var checkVlue = toFloatString(threshold); + var darkColorString = colorToVec4(Color$1.parse(darkColor)); + var lightColorString = colorToVec4(Color$1.parse(lightColor)); + + return shader('\n if ((pixelColor.r + pixelColor.g + pixelColor.b) > ' + checkVlue + ') {\n outColor = vec4(' + lightColorString + '.rgb, pixelColor.a);\n } else {\n outColor = vec4(' + darkColorString + '.rgb, pixelColor.a);\n }\n '); +} + +/* + * @param {Number} amount -1..1 , value < 0 is darken, value > 0 is brighten + */ +function brightness$2() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + + var C = toFloatString(parseParamNumber$1(amount)); + + return shader('\n outColor = pixelColor + (' + C + ');\n '); +} + +function matrix$3() { + var $a = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; + var $b = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0; + var $c = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 0; + var $d = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 0; + var $e = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : 0; + var $f = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : 0; + var $g = arguments.length > 6 && arguments[6] !== undefined ? arguments[6] : 0; + var $h = arguments.length > 7 && arguments[7] !== undefined ? arguments[7] : 0; + var $i = arguments.length > 8 && arguments[8] !== undefined ? arguments[8] : 0; + var $j = arguments.length > 9 && arguments[9] !== undefined ? arguments[9] : 0; + var $k = arguments.length > 10 && arguments[10] !== undefined ? arguments[10] : 0; + var $l = arguments.length > 11 && arguments[11] !== undefined ? arguments[11] : 0; + var $m = arguments.length > 12 && arguments[12] !== undefined ? arguments[12] : 0; + var $n = arguments.length > 13 && arguments[13] !== undefined ? arguments[13] : 0; + var $o = arguments.length > 14 && arguments[14] !== undefined ? arguments[14] : 0; + var $p = arguments.length > 15 && arguments[15] !== undefined ? arguments[15] : 0; + + + var matrix = [$a, $b, $c, $d, $e, $f, $g, $h, $i, $j, $k, $l, $m, $n, $o, $p].map(toFloatString); + + return shader('\n\n outColor = vec4(\n ' + matrix[0] + ' * pixelColor.r + ' + matrix[1] + ' * pixelColor.g + ' + matrix[2] + ' * pixelColor.b + ' + matrix[3] + ' * pixelColor.a,\n ' + matrix[4] + ' * pixelColor.r + ' + matrix[5] + ' * pixelColor.g + ' + matrix[6] + ' * pixelColor.b + ' + matrix[7] + ' * pixelColor.a,\n ' + matrix[8] + ' * pixelColor.r + ' + matrix[9] + ' * pixelColor.g + ' + matrix[10] + ' * pixelColor.b + ' + matrix[11] + ' * pixelColor.a,\n ' + matrix[12] + ' * pixelColor.r + ' + matrix[13] + ' * pixelColor.g + ' + matrix[14] + ' * pixelColor.b + ' + matrix[15] + ' * pixelColor.a\n ); \n '); +} + +function brownie$1() { + + return matrix$3(0.5997023498159715, 0.34553243048391263, -0.2708298674538042, 0, -0.037703249837783157, 0.8609577587992641, 0.15059552388459913, 0, 0.24113635128153335, -0.07441037908422492, 0.44972182064877153, 0, 0, 0, 0, 1); +} + +/* + * @param {Number} amount 0..1 + */ +function clip$1() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; + + var C = toFloatString(parseParamNumber$1(amount)); + + return shader('\n outColor = vec4(\n (pixelColor.r > 1.0 - ' + C + ') ? 1.0 : 0.0,\n (pixelColor.g > 1.0 - ' + C + ') ? 1.0 : 0.0,\n (pixelColor.b > 1.0 - ' + C + ') ? 1.0 : 0.0,\n pixelColor.a \n );\n '); +} + +function chaos() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 10; + + var C = toFloatString(parseParamNumber$1(amount)); + + return shader('\n vec2 st = pixelColor.st;\n st *= ' + C + ';\n \n vec2 ipos = floor(st); // get the integer coords\n\n vec3 color = vec3(random( ipos ));\n\n outColor = vec4(color, pixelColor.a);\n '); +} + +/* + * @param {Number} amount 0..1 + */ +function contrast$2() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + + var C = toFloatString(parseParamNumber$1(amount)); + + return shader('\n outColor = pixelColor * ' + C + ';\n '); +} + +/* + * @param {Number} amount -1..1 , value < 0 is darken, value > 0 is brighten + */ +function gamma$1() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + + var C = toFloatString(parseParamNumber$1(amount)); + + return shader('\n outColor = vec4(pow(pixelColor.r, ' + C + '), pow(pixelColor.g, ' + C + '), pow(pixelColor.b, ' + C + '), pixelColor.a );\n '); +} + +/** + * F.gradient('red', 'blue', 'yellow', 'white', 10) + * F.gradient('red, blue, yellow, white, 10') + */ +function gradient$2() { + // 전체 매개변수 기준으로 파싱 + // 색이 아닌 것 기준으로 scale 변수로 인식 + + var params = [].concat(Array.prototype.slice.call(arguments)); + + if (params.length === 1 && typeof params[0] === 'string') { + params = Color$1.convertMatchesArray(params[0]); + } + + params = params.map(function (arg) { + return arg; + }).join(', '); + + var colors = Color$1.parseGradient(params); + + colors[0][1] = 0; + colors[colors.length - 1][1] = 1; + + colors = colors.map(function (c) { + var _Color$parse = Color$1.parse(c[0]), + r = _Color$parse.r, + g = _Color$parse.g, + b = _Color$parse.b, + a = _Color$parse.a; + + return [{ r: r, g: g, b: b, a: a }, c[1]]; + }); + + var temp = []; + + for (var i = 0, len = colors.length; i < len - 1; i++) { + var start = colors[i]; + var end = colors[i + 1]; + + var startColor = colorToVec4(start[0]); + var endColor = colorToVec4(end[0]); + + var startRate = toFloatString(start[1]); + var endRate = toFloatString(end[1]); + + temp.push('\n if (' + startRate + ' <= rate && rate < ' + endRate + ') {\n outColor = mix(' + startColor + ', ' + endColor + ', (rate - ' + startRate + ')/(' + endRate + ' - ' + startRate + '));\n }\n '); + } + + return shader('\n float rate = (pixelColor.r * 0.2126 + pixelColor.g * 0.7152 + pixelColor.b * 0.0722); \n\n ' + temp.join('\n') + ' \n '); +} + +/** + * + * @param {Number} amount 0..1 + */ +function grayscale$1() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + + var C = parseParamNumber$1(amount); + + if (C > 1) C = 1; + + return matrix$3(0.2126 + 0.7874 * (1 - C), 0.7152 - 0.7152 * (1 - C), 0.0722 - 0.0722 * (1 - C), 0, 0.2126 - 0.2126 * (1 - C), 0.7152 + 0.2848 * (1 - C), 0.0722 - 0.0722 * (1 - C), 0, 0.2126 - 0.2126 * (1 - C), 0.7152 - 0.7152 * (1 - C), 0.0722 + 0.9278 * (1 - C), 0, 0, 0, 0, 1); +} + +//http://lolengine.net/blog/2013/07/27/rgb-to-hsv-in-glsl +/* + * @param {Number} amount 0..1 , (real value 0..360) + */ +function hue$1() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + + var C = toFloatString(parseParamNumber$1(amount)); + + return shader('\n vec3 hsv = rgb2hsv(pixelColor.rgb);\n hsv.x += ' + C + ';\n outColor = vec4(hsv2rgb(hsv).rgb, pixelColor.a);\n '); +} + +function invert$1() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + + var C = toFloatString(parseParamNumber$1(amount)); + + return shader('\n outColor = vec4(\n (1.0 - pixelColor.r) * ' + C + ',\n (1.0 - pixelColor.g) * ' + C + ',\n (1.0 - pixelColor.b) * ' + C + ',\n pixelColor.a\n );\n '); +} + +function kodachrome$1() { + + return matrix$3(1.1285582396593525, -0.3967382283601348, -0.03992559172921793, 0, -0.16404339962244616, 1.0835251566291304, -0.05498805115633132, 0, -0.16786010706155763, -0.5603416277695248, 1.6014850761964943, 0, 0, 0, 0, 1); +} + +/** + * + * @param {Number} amount 0..1 + */ +function noise$1() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + + + var C = Math.abs(parseParamNumber$1(amount)); + var min = toFloatString(-C); + var max = toFloatString(C); + return shader('\n float rnd = ' + min + ' + random( pixelColor.st ) * (' + max + ' - ' + min + ');\n\n outColor = vec4(pixelColor.rgb + rnd, 1.0);\n '); +} + +/** + * + * @param {Number} amount 0..1 + */ +function opacity$1() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + + var C = toFloatString(parseParamNumber$1(amount)); + + return shader('\n outColor = vec4(pixelColor.rgb, pixelColor.a * ' + C + ');\n '); +} + +function polaroid$1() { + + return matrix$3(1.438, -0.062, -0.062, 0, -0.122, 1.378, -0.122, 0, -0.016, -0.016, 1.483, 0, 0, 0, 0, 1); +} + +/* + * @param {Number} amount 0..1 + */ +function saturation$1() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; + + var L = 1 - Math.abs(parseParamNumber$1(amount)); + + return matrix$3(L, 0, 0, 0, 0, L, 0, 0, 0, 0, L, 0, 0, 0, 0, L); +} + +/* + * @param {Number} amount 0..100 + */ +function sepia$1() { + var amount = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + + var C = parseParamNumber$1(amount); + if (C > 1) C = 1; + + return matrix$3(0.393 + 0.607 * (1 - C), 0.769 - 0.769 * (1 - C), 0.189 - 0.189 * (1 - C), 0, 0.349 - 0.349 * (1 - C), 0.686 + 0.314 * (1 - C), 0.168 - 0.168 * (1 - C), 0, 0.272 - 0.272 * (1 - C), 0.534 - 0.534 * (1 - C), 0.131 + 0.869 * (1 - C), 0, 0, 0, 0, 1); +} + +function shade$1() { + var redValue = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + var greenValue = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 1; + var blueValue = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 1; + + var r = toFloatString(parseParamNumber$1(redValue) / 255); + var g = toFloatString(parseParamNumber$1(greenValue) / 255); + var b = toFloatString(parseParamNumber$1(blueValue) / 255); + + return shader('\n outColor = vec4(\n pixelColor.r * ' + r + ',\n pixelColor.g * ' + g + ',\n pixelColor.b * ' + b + ',\n pixelColor.a\n );\n '); +} + +function shift$1() { + + return matrix$3(1.438, -0.062, -0.062, 0, -0.122, 1.378, -0.122, 0, -0.016, -0.016, 1.483, 0, 0, 0, 0, 1); +} + +function solarize$1(redValue, greenValue, blueValue) { + var r = toFloatString(parseParamNumber$1(redValue)); + var g = toFloatString(parseParamNumber$1(greenValue)); + var b = toFloatString(parseParamNumber$1(blueValue)); + + return shader('\n outColor = vec4(\n (pixelColor.r < ' + r + ') ? 1.0 - pixelColor.r: pixelColor.r,\n (pixelColor.g < ' + g + ') ? 1.0 - pixelColor.g: pixelColor.g,\n (pixelColor.b < ' + b + ') ? 1.0 - pixelColor.b: pixelColor.b,\n pixelColor.a\n );\n '); +} + +function technicolor$1() { + + return matrix$3(1.9125277891456083, -0.8545344976951645, -0.09155508482755585, 0, -0.3087833385928097, 1.7658908555458428, -0.10601743074722245, 0, -0.231103377548616, -0.7501899197440212, 1.847597816108189, 0, 0, 0, 0, 1); +} + +function thresholdColor$1() { + var scale = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 1; + + scale = toFloatString(parseParamNumber$1(scale)); + + return shader('\n float c = ( (pixelColor.r * 0.2126 + pixelColor.g * 0.7152 + pixelColor.b * 0.0722) ) >= ' + scale + ' ? 1.0 : 0.0;\n\n outColor = vec4(c, c, c, pixelColor.a);\n '); +} + +/* + * @param {Number} amount 0..100 + */ +function threshold$1() { + var scale = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 200; + var amount = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 100; + + return thresholdColor$1(scale, amount, false); +} + +/** + * + * @param {*} redTint 0..1 + * @param {*} greenTint 0..1 + * @param {*} blueTint 0..1 + */ +function tint$1 () { + var redTint = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; + var greenTint = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0; + var blueTint = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 0; + + var r = parseParamNumber$1(redTint); + var g = parseParamNumber$1(greenTint); + var b = parseParamNumber$1(blueTint); + + return shader('\n outColor = vec4(\n pixelColor.r += (1 - pixelColor.r) * ' + r + ',\n pixelColor.g += (1 - pixelColor.g) * ' + g + ',\n pixelColor.b += (1 - pixelColor.b) * ' + b + ',\n pixelColor.a\n );\n '); +} + +var pixel$2 = { + bitonal: bitonal$1, + brightness: brightness$2, + brownie: brownie$1, + clip: clip$1, + chaos: chaos, + contrast: contrast$2, + gamma: gamma$1, + gradient: gradient$2, + grayscale: grayscale$1, + hue: hue$1, + invert: invert$1, + kodachrome: kodachrome$1, + matrix: matrix$3, + noise: noise$1, + opacity: opacity$1, + polaroid: polaroid$1, + saturation: saturation$1, + sepia: sepia$1, + shade: shade$1, + shift: shift$1, + solarize: solarize$1, + technicolor: technicolor$1, + threshold: threshold$1, + 'threshold-color': thresholdColor$1, + tint: tint$1 +}; + +function kirsch$1() { + return multi$3('kirsch-horizontal kirsch-vertical'); +} + +function sobel$1() { + return multi$3('sobel-horizontal sobel-vertical'); +} + +function vintage$1() { + return multi$3('brightness(0.15) saturation(-0.2) gamma(1.8)'); +} + +var multi$4 = { + kirsch: kirsch$1, + sobel: sobel$1, + vintage: vintage$1 +}; + +var GLFilter = _extends({}, matrix$2, pixel$2, multi$4); + +var TEXTURE_INDEX = 0; + +var GLCanvas = function () { + function GLCanvas() { + var opt = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : { + width: '400px', + height: '300px' + }; + classCallCheck(this, GLCanvas); + + this.img = opt.img; + this.width = parseFloat(this.img.width || opt.width || '400px'); + this.height = parseFloat(this.img.height || opt.height || '300px'); + this.init(); + } + + createClass(GLCanvas, [{ + key: 'resize', + value: function resize() { + this.canvas.width = this.width; + this.canvas.height = this.height; + this.canvas.style.width = this.width + 'px'; + this.canvas.style.height = this.height + 'px'; + + this.viewport(); + } + + /* Canvas 비우기, 비울 때 색 지정하기 */ + + }, { + key: 'clear', + value: function clear() { + var r = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; + var g = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0; + var b = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 0; + var a = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 0; + + var gl = this.gl; + + gl.clearColor(r, g, b, a); + gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); + } + + /* viewport 설정, 기본적으로 canvas 의 크기로 고정 */ + + }, { + key: 'viewport', + value: function viewport(x, y, width, height) { + var gl = this.gl; + + gl.viewport(x || 0, y || 0, width || gl.canvas.width, height || gl.canvas.height); + } + + // canvas 초기화 + // gl context 구하기 + + }, { + key: 'initCanvas', + value: function initCanvas(vertexSource, fragmentSource) { + this.canvas = document.createElement('canvas'); + + this.gl = this.canvas.getContext('webgl2'); + + if (!this.gl) { + throw new Error("you need webgl2 support"); + } + + // program 생성 + this.program = this.createProgram(vertexSource, fragmentSource); + + // this.clear() + this.resize(); + + // buffer 설정 + this.initBuffer(); + } + }, { + key: 'draw', + value: function draw() { + var primitiveType = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 'TRIANGLES'; + var offset = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0; + var count = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 6; + + var gl = this.gl; + + gl.drawArrays(gl[primitiveType], offset, count); + } + }, { + key: 'triangles', + value: function triangles() { + var offset = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; + var count = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 6; + + this.draw('TRIANGLES', offset, count); + } + }, { + key: 'uniform2f', + value: function uniform2f() { + var _gl; + + for (var _len = arguments.length, args = Array(_len), _key = 0; _key < _len; _key++) { + args[_key] = arguments[_key]; + } + + var key = args.shift(); + + (_gl = this.gl).uniform2f.apply(_gl, [this.locations[key]].concat(args)); + } + }, { + key: 'uniform1f', + value: function uniform1f() { + var _gl2; + + for (var _len2 = arguments.length, args = Array(_len2), _key2 = 0; _key2 < _len2; _key2++) { + args[_key2] = arguments[_key2]; + } + + var key = args.shift(); + + (_gl2 = this.gl).uniform1f.apply(_gl2, [this.locations[key]].concat(args)); + } + }, { + key: 'uniform1fv', + value: function uniform1fv() { + var _gl3; + + for (var _len3 = arguments.length, args = Array(_len3), _key3 = 0; _key3 < _len3; _key3++) { + args[_key3] = arguments[_key3]; + } + + var key = args.shift(); + + (_gl3 = this.gl).uniform1fv.apply(_gl3, [this.locations[key]].concat(args)); + } + }, { + key: 'uniform1i', + value: function uniform1i() { + var _gl4; + + for (var _len4 = arguments.length, args = Array(_len4), _key4 = 0; _key4 < _len4; _key4++) { + args[_key4] = arguments[_key4]; + } + + var key = args.shift(); + + (_gl4 = this.gl).uniform1i.apply(_gl4, [this.locations[key]].concat(args)); + } + }, { + key: 'useProgram', + value: function useProgram() { + var gl = this.gl; + + gl.useProgram(this.program); + } + }, { + key: 'bindBuffer', + value: function bindBuffer(key, data) { + var drawType = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'STATIC_DRAW'; + + var gl = this.gl; + + if (!this.buffers[key]) { + this.buffers[key] = gl.createBuffer(); + } + + gl.bindBuffer(gl.ARRAY_BUFFER, this.buffers[key]); + + if (data) { + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl[drawType]); + } + } + }, { + key: 'enable', + value: function enable(key) { + var gl = this.gl; + + // array attribute 를 활성화 시킴 + gl.enableVertexAttribArray(this.locations[key]); + } + }, { + key: 'location', + value: function location(key) { + var type = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : "attribute"; + + if (type === 'attribute') { + this.locations[key] = this.gl.getAttribLocation(this.program, key); + } else if (type === 'uniform') { + this.locations[key] = this.gl.getUniformLocation(this.program, key); + } + } + }, { + key: 'a', + value: function a(key) { + return this.location(key); + } + }, { + key: 'u', + value: function u(key) { + return this.location(key, "uniform"); + } + }, { + key: 'pointer', + value: function pointer(key) { + var type = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 'FLOAT'; + var size = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 2; + var normalize = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : false; + var stride = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : 0; + var offset = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : 0; + + var gl = this.gl; + + gl.vertexAttribPointer(this.locations[key], size, gl[type], normalize, stride, offset); + } + }, { + key: 'bufferData', + value: function bufferData() { + var data = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; + + var gl = this.gl; + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(data), gl.STATIC_DRAW); + } + }, { + key: 'isPowerOf2', + value: function isPowerOf2(value) { + return (value & value - 1) == 0; + } + }, { + key: 'bindTexture', + value: function bindTexture(key) { + var img = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : undefined; + var mipLevel = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 0; + var internalFormat = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 'RGBA'; + var srcFormat = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : 'RGBA'; + var srcType = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : 'UNSIGNED_BYTE'; + + var gl = this.gl; + + if (arguments.length == 1) { + gl.bindTexture(gl.TEXTURE_2D, this.textures[key]); + return; + } + + if (!this.textures[key]) { + this.textures[key] = gl.createTexture(); + } + + this.textureIndex[key] = TEXTURE_INDEX++; + // this.activeTexture(key) + gl.bindTexture(gl.TEXTURE_2D, this.textures[key]); + + this.setTextureParameter(); + + gl.texImage2D(gl.TEXTURE_2D, mipLevel, gl[internalFormat], gl[srcFormat], gl[srcType], img.newImage || img); + } + }, { + key: 'bindColorTexture', + value: function bindColorTexture(key, data) { + var width = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 256; + var height = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 1; + var mipLevel = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : 0; + var internalFormat = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : 'RGBA'; + var srcFormat = arguments.length > 6 && arguments[6] !== undefined ? arguments[6] : 'RGBA'; + var srcType = arguments.length > 7 && arguments[7] !== undefined ? arguments[7] : 'UNSIGNED_BYTE'; + + var gl = this.gl; + + if (!this.textures[key]) { + this.textures[key] = gl.createTexture(); + } + + this.textureIndex[key] = TEXTURE_INDEX++; + gl.bindTexture(gl.TEXTURE_2D, this.textures[key]); + + this.setTextureParameter(); + + gl.texImage2D(gl.TEXTURE_2D, mipLevel, gl[internalFormat], width, height, 0, gl[srcFormat], gl[srcType], new Uint8Array(data)); + } + }, { + key: 'bindEmptyTexture', + value: function bindEmptyTexture(key, width, height) { + var mipLevel = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 0; + var internalFormat = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : 'RGBA'; + var srcFormat = arguments.length > 5 && arguments[5] !== undefined ? arguments[5] : 'RGBA'; + var srcType = arguments.length > 6 && arguments[6] !== undefined ? arguments[6] : 'UNSIGNED_BYTE'; + + var gl = this.gl; + + if (!this.textures[key]) { + this.textures[key] = gl.createTexture(); + } + + this.textureIndex[key] = TEXTURE_INDEX++; + gl.bindTexture(gl.TEXTURE_2D, this.textures[key]); + + this.setTextureParameter(); + + var border = 0; + var data = null; + + gl.texImage2D(gl.TEXTURE_2D, mipLevel, gl[internalFormat], width, height, border, gl[srcFormat], gl[srcType], data); + } + }, { + key: 'setTextureParameter', + value: function setTextureParameter() { + var gl = this.gl; + + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST); + gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST); + } + }, { + key: 'bindFrameBufferWithTexture', + value: function bindFrameBufferWithTexture(key, textureKey, width, height) { + this.bindEmptyTexture(textureKey, width, height); + this.bindFrameBuffer(key, textureKey); + } + }, { + key: 'enumToString', + value: function enumToString(value) { + var gl = this.gl; + + if (value === 0) { + return "NONE"; + } + for (var key in gl) { + if (gl[key] === value) { + return key; + } + } + return "0x" + value.toString(16); + } + }, { + key: 'bindFrameBuffer', + value: function bindFrameBuffer(key) { + var textureKey = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; + + var gl = this.gl; + + if (arguments.length === 1) { + gl.bindFramebuffer(gl.FRAMEBUFFER, key == null ? null : this.framebuffers[key]); + return; + } + + if (!this.framebuffers[key]) { + // 프레임버퍼 생성하기 + this.framebuffers[key] = gl.createFramebuffer(); + } + + gl.bindFramebuffer(gl.FRAMEBUFFER, this.framebuffers[key]); + + // framebuffer 에 texture2d 연결 + var mipLevel = 0; + var attachmentPoint = gl.COLOR_ATTACHMENT0; // framebuffer 를 attachmentPoint 에 연결한다. + // framebuffer 는 데이타를 가지고 있지 않고 연결 고리만 가지고 있다. + gl.framebufferTexture2D(gl.FRAMEBUFFER, attachmentPoint, gl.TEXTURE_2D, this.textures[textureKey], mipLevel); + + // framebuffer 상태 체크 하기 + // framebuffer 를 더 이상 할당 못할 수도 있음. + var status = gl.checkFramebufferStatus(gl.FRAMEBUFFER); + + // console.log(this.enumToString(attachmentPoint), this.enumToString(status), key, this.textures[textureKey]); + + if (status !== gl.FRAMEBUFFER_COMPLETE) { + return; + } + } + }, { + key: 'bindVA', + value: function bindVA() { + var gl = this.gl; + + if (!this.vao) { + this.vao = gl.createVertexArray(); + } + + gl.bindVertexArray(this.vao); + } + }, { + key: 'bindAttr', + value: function bindAttr(key, data) { + var drawType = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'STATIC_DRAW'; + var size = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : 2; + + // 버퍼를 만들고 데이타를 연결한다. + this.bindBuffer(key, data, drawType); + + //array 변수를 사용할 수 있도록 활성화 시킨다. + this.enable(key); + + // 포인터를 지정한다. + // array 변수가 어떻게 iteration 될지 지정한다. size 는 한번에 연산될 요소 개수 + // size 가 2 라고 했을 때 2개씩 하나의 iteration 에 들어간다. + // 즉, (x, y) 가 한번에 들어감 + this.pointer(key, 'FLOAT', size); + } + + /* + shader 에서 사용하는 Attribute, Uniform 변수 설정 + 변수 설정을 간소화 할 필요도 있을 듯 하다. + */ + + }, { + key: 'initBuffer', + value: function initBuffer() { + var _canvas = this.canvas, + width = _canvas.width, + height = _canvas.height; + + // console.log(width, height) + + // 선언된 변수 location 지정 하기 + // location 을 지정해야 GLSL 에서 해당 변수와 연결할 수 있다. 언제? + + this.a("a_position"); + this.a("a_texCoord"); + this.u("u_resolution"); + this.u("u_image"); + this.u("u_flipY"); + + this.u("u_kernelSelect"); + this.u("u_filterIndex"); + + this.u("u_kernel9[0]"); + this.u("u_kernel9Weight"); + this.u("u_kernel25[0]"); + this.u("u_kernel25Weight"); + this.u("u_kernel49[0]"); + this.u("u_kernel49Weight"); + this.u("u_kernel81[0]"); + this.u("u_kernel81Weight"); + + this.bindVA(); + + // 단순 변수를 초기화 하고 + this.bindAttr("a_position", [0, 0, width, 0, 0, height, 0, height, width, 0, width, height], 'STATIC_DRAW', 2 /* components for iteration */); + + // 변수에 데이타를 연결할다. + this.bindAttr("a_texCoord", [0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 'STATIC_DRAW', 2 /* components for iteration */); + + // texture 는 img 로 할 수도 있고 + this.bindTexture("u_image", this.img); + + // 비어있는 texture 도 만들 수 있다. + // 객체로 제어할까? + // texture 를 framebuffer 로 바로 대응시킨다. + // 이후 framebuffer 가 변경되면 img_texture 가 바뀐다. + this.bindFrameBufferWithTexture("frame_buffer_0", "img_texture_0", width, height); + this.bindFrameBufferWithTexture("frame_buffer_1", "img_texture_1", width, height); + } + }, { + key: 'activeTexture', + value: function activeTexture() { + var index = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : 0; + + var gl = this.gl; + + gl.activeTexture(gl.TEXTURE0 + index); + } + }, { + key: 'drawFilter', + value: function drawFilter() { + var _this = this; + + var gl = this.gl; + + this.resize(); + this.clear(); + + this.useProgram(); + + this.bindVA(); + + this.activeTexture(0); + this.bindTexture('u_image'); + + this.uniform1i("u_image", 0); + this.uniform1f("u_flipY", 1); + + var _gl$canvas = gl.canvas, + width = _gl$canvas.width, + height = _gl$canvas.height; + + + this.eachFilter(function (f, index) { + + _this.bindFrameBuffer('frame_buffer_' + index % 2); + _this.uniform2f("u_resolution", width, height); + _this.viewport(0, 0, width, height); + + _this.effectFilter(f); + + // 다음 드로잉을 위해 방금 렌더링 한 텍스처를 사용합니다. + _this.bindTexture('img_texture_' + index % 2); + }); + + this.uniform1f("u_flipY", -1); + this.bindFrameBuffer(null); + this.uniform2f("u_resolution", width, height); + this.viewport(0, 0, width, height); + + // clear 가 있는 이유는? + this.clear(); + + this.effectFilter("normal"); + } + }, { + key: 'effectFilter', + value: function effectFilter(filterFunction) { + + if (typeof filterFunction == 'string') { + filterFunction = (GLFilter[filterFunction] || GLFilter.normal).call(GLFilter); + } + + if (filterFunction.type == 'convolution') { + this.uniform1f("u_kernelSelect", filterFunction.length); + this.uniform1f("u_filterIndex", -1.0); + this.uniform1fv('u_kernel' + filterFunction.length + '[0]', filterFunction.content); + this.uniform1f('u_kernel' + filterFunction.length + 'Weight', this.computeKernelWeight(filterFunction.content)); + } else { + + this.uniform1f("u_kernelSelect", -1.0); + this.uniform1f("u_filterIndex", filterFunction.index); + } + + this.triangles(0 /* 시작 지점 */, 6 /* 좌표(vertex, 꼭지점) 개수 */); // 총 6개를 도는데 , triangles 니깐 3개씩 묶어서 2번 돈다. + } + }, { + key: 'computeKernelWeight', + value: function computeKernelWeight(kernel) { + var weight = kernel.reduce(function (prev, curr) { + return prev + curr; + }); + return weight <= 0 ? 1 : weight; + } + }, { + key: 'createProgram', + value: function createProgram(vertexSource, fragmentSource) { + + var gl = this.gl; + + var program = gl.createProgram(); + + this.vertexShader = this.createVertexShader(vertexSource); + this.fragmentShader = this.createFragmentShader(fragmentSource); + + // console.log(fragmentSource) + + + gl.attachShader(program, this.vertexShader); + gl.attachShader(program, this.fragmentShader); + + gl.linkProgram(program); + + var success = gl.getProgramParameter(program, gl.LINK_STATUS); + if (success) { + + return program; + } + + console.error(gl.getProgramInfoLog(program)); + gl.deleteProgram(program); + } + }, { + key: 'createShader', + value: function createShader(type, source) { + var gl = this.gl; + + var shader$$1 = gl.createShader(type); + gl.shaderSource(shader$$1, source); + gl.compileShader(shader$$1); + + var success = gl.getShaderParameter(shader$$1, gl.COMPILE_STATUS); + + if (success) { + return shader$$1; + } + + console.error(gl.getShaderInfoLog(shader$$1)); + gl.deleteShader(shader$$1); + } + }, { + key: 'createVertexShader', + value: function createVertexShader(vertexSource) { + var gl = this.gl; + + return this.createShader(gl.VERTEX_SHADER, vertexSource); + } + }, { + key: 'createFragmentShader', + value: function createFragmentShader(fragmentSource) { + var gl = this.gl; + + return this.createShader(gl.FRAGMENT_SHADER, fragmentSource); + } + }, { + key: 'eachFilter', + value: function eachFilter(callback) { + this.filterList.forEach(callback); + } + }, { + key: 'init', + value: function init() { + this.locations = {}; + this.buffers = {}; + this.framebuffers = {}; + this.textures = {}; + this.textureIndex = {}; + this.hasTexParameter = {}; + } + }, { + key: 'destroy', + value: function destroy() { + var gl = this.gl; + + this.init(); + + gl.deleteProgram(this.program); + } + }, { + key: 'filter', + value: function filter(filterList, doneCallback) { + + this.filterList = filterList; + + this.initCanvas(makeVertexShaderSource(), makeFragmentShaderSource(this.filterList)); + + this.drawFilter(); + + if (typeof doneCallback == 'function') { + + doneCallback(this); + } + } + }]); + return GLCanvas; +}(); + +var GL$1 = { + GLCanvas: GLCanvas +}; + +var functions = { + filter: filter +}; + + + + + + +function makeFilterFunction(filterObj) { + var filterName = filterObj.arr[0]; + var f = GLFilter[filterName]; + + var arr = filterObj.arr; + arr.shift(); + + var result = f.apply(this, arr); + + return result; +} + +/** + * 겹쳐져 있는 Filter 함수를 1차원으로 나열한다. + * ex) ['sobel'] => ['sobel-horizontal', 'sobel-vertial'] + * + * @param {String|Array} filterString + */ +function flatFilter(filterString) { + + var filter_list = []; + + if (typeof filterString == 'string') { + filter_list = matches$1(filterString); + } else if (Array.isArray(filterString)) { + filter_list = filterString; + } + + var allFilter = []; + + filter_list.forEach(function (filterObj) { + var filterName = filterObj.arr[0]; + + if (GLFilter[filterName]) { + var f = makeFilterFunction(filterObj); + + if (f.type == 'convolution' || f.type == 'shader') { + allFilter.push(f); + } else { + f.forEach(function (subFilter) { + allFilter = allFilter.concat(flatFilter(subFilter)); + }); + } + } + }); + + // console.log(filter_list, allFilter) + + return allFilter; +} + +function filter(img, filterString, callback, opt) { + + var canvas = new GL$1.GLCanvas({ + width: opt.width || img.width, + height: opt.height || img.height, + img: img + }); + + canvas.filter(flatFilter(filterString), function done() { + if (typeof callback == 'function') { + callback(canvas); + } + }); +} + +var GL = _extends({}, GL$1, functions); + +function palette(colors) { + var k = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 6; + var exportFormat = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : 'hex'; + + + if (colors.length > k) { + colors = kmeans(colors, k); + } + + return colors.map(function (c) { + return format(c, exportFormat); + }); +} + +function ImageToRGB(url) { + var callbackOrOption = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + var callback = arguments[2]; + + + if (!callback) { + var img = new ImageLoader(url); + img.loadImage(function () { + if (typeof callbackOrOption == 'function') { + callbackOrOption(img.toRGB()); + } + }); + } else if (callback) { + var img = new ImageLoader(url, callbackOrOption); + img.loadImage(function () { + if (typeof callback == 'function') { + callback(img.toRGB()); + } + }); + } +} + +function ImageToCanvas(url, filter, callback) { + var opt = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : { frameTimer: 'full' }; + + ImageToURL(url, filter, callback, Object.assign({ + returnTo: 'canvas' + }, opt)); +} + +function ImageToURL(url, filter, callback) { + var opt = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : { frameTimer: 'full' }; + + var img = new ImageLoader(url); + img.loadImage(function () { + img.toArray(filter, function (datauri) { + if (typeof callback == 'function') { + callback(datauri); + } + }, opt); + }); +} + +function GLToCanvas(url, filter, callback) { + var opt = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {}; + + var img = new ImageLoader(url); + img.load(function () { + GL.filter(img.newImage, filter, function done(datauri) { + if (typeof callback == 'function') { + callback(datauri); + } + }, opt); + }); +} + +function histogram(url, callback) { + var opt = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; + + var img = new ImageLoader(url); + img.loadImage(function () { + if (typeof callback == 'function') { + callback(img.toHistogram(opt)); + } + }); +} + +function histogramToPoints(points) { + var tension = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0.2; + + + var controlPoints = []; + for (var i = 0; i < points.length; i++) { + var p = points[i]; + if (i == 0) { + controlPoints[i] = []; + continue; + } + + if (i == points.length - 1) { + controlPoints[i] = []; + continue; + } + + var prevPoint = points[i - 1]; + var nextPoint = points[i + 1]; + + // 기울기 + var M = (nextPoint[1] - prevPoint[1]) / (nextPoint[0] - prevPoint[0]); + + var newControlPoint = [prevPoint[0] + (nextPoint[0] - prevPoint[0]) * tension, prevPoint[1] + (nextPoint[1] - prevPoint[1]) * tension]; + + var controlPoint = [[].concat(toConsumableArray(prevPoint)), /* start */ + [].concat(newControlPoint) /* end */ + ]; + + var P = Math.sqrt(Math.pow(p[0] - prevPoint[0], 2) + Math.pow(p[1] - prevPoint[1], 2)); + var N = Math.sqrt(Math.pow(nextPoint[0] - p[0], 2) + Math.pow(nextPoint[1] - p[1], 2)); + + var rate = P / N; + + var dx = controlPoint[0][0] + (controlPoint[1][0] - controlPoint[0][0]) * rate; + var dy = controlPoint[0][1] + (controlPoint[1][1] - controlPoint[0][1]) * rate; + + controlPoint[0][0] += p[0] - dx; + controlPoint[0][1] += p[1] - dy; + controlPoint[1][0] += p[0] - dx; + controlPoint[1][1] += p[1] - dy; + + controlPoints[i] = controlPoint; + } + + return controlPoints; +} + +function ImageToHistogram(url, callback) { + var opt = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : { width: 200, height: 100 }; + + + var img = new ImageLoader(url); + img.loadImage(function () { + Canvas.createHistogram(opt.width || 200, opt.height || 100, img.toHistogram(opt), function (canvas) { + if (typeof callback == 'function') callback(canvas.toDataURL('image/png')); + }, opt); + }); +} + +var image = { + palette: palette, + ImageToCanvas: ImageToCanvas, + ImageToHistogram: ImageToHistogram, + ImageToRGB: ImageToRGB, + ImageToURL: ImageToURL, + GLToCanvas: GLToCanvas, + histogram: histogram, + histogramToPoints: histogramToPoints +}; + +var Color$1 = _extends({}, formatter, math, mixin, parser, fromYCrCb, fromRGB, fromCMYK, fromHSV, fromHSL, fromLAB, image); + +var hue_color = [{ rgb: '#ff0000', start: .0 }, { rgb: '#ffff00', start: .17 }, { rgb: '#00ff00', start: .33 }, { rgb: '#00ffff', start: .50 }, { rgb: '#0000ff', start: .67 }, { rgb: '#ff00ff', start: .83 }, { rgb: '#ff0000', start: 1 }]; + +function checkHueColor(p) { + var startColor, endColor; + + for (var i = 0; i < hue_color.length; i++) { + if (hue_color[i].start >= p) { + startColor = hue_color[i - 1]; + endColor = hue_color[i]; + break; + } + } + + if (startColor && endColor) { + return Color$1.interpolateRGB(startColor, endColor, (p - startColor.start) / (endColor.start - startColor.start)); + } + + return hue_color[0].rgb; +} + +function initHueColors() { + for (var i = 0, len = hue_color.length; i < len; i++) { + var hue = hue_color[i]; + + var obj = Color$1.parse(hue.rgb); + + hue.r = obj.r; + hue.g = obj.g; + hue.b = obj.b; + } +} + +initHueColors(); + +var HueColor = { + colors: hue_color, + checkHueColor: checkHueColor +}; + +// TODO: worker run +var ImageFilter = _extends({}, FilterList, functions$1); + +var Util = { + Color: Color$1, + HueColor: HueColor, + ColorNames: ColorNames, + ImageFilter: ImageFilter, + GL: GL, + Canvas: Canvas, + ImageLoader: ImageLoader +}; + +var color = Color$1.color; + +var counter = 0; +var cached = []; + +var Dom = function () { + function Dom(tag, className, attr) { + classCallCheck(this, Dom); + + + if (typeof tag != 'string') { + this.el = tag; + } else { + + var el = document.createElement(tag); + this.uniqId = counter++; + + if (className) { + el.className = className; + } + + attr = attr || {}; + + for (var k in attr) { + el.setAttribute(k, attr[k]); + } + + this.el = el; + } + } + + createClass(Dom, [{ + key: 'attr', + value: function attr(key, value) { + if (arguments.length == 1) { + return this.el.getAttribute(key); + } + + this.el.setAttribute(key, value); + + return this; + } + }, { + key: 'closest', + value: function closest(cls) { + + var temp = this; + var checkCls = false; + + while (!(checkCls = temp.hasClass(cls))) { + if (temp.el.parentNode) { + temp = new Dom(temp.el.parentNode); + } else { + return null; + } + } + + if (checkCls) { + return temp; + } + + return null; + } + }, { + key: 'checked', + value: function checked() { + return this.el.checked; + } + }, { + key: 'removeClass', + value: function removeClass(cls) { + this.el.className = (' ' + this.el.className + ' ').replace(' ' + cls + ' ', ' ').trim(); + + return this; + } + }, { + key: 'hasClass', + value: function hasClass(cls) { + if (!this.el.className) { + return false; + } else { + var newClass = ' ' + this.el.className + ' '; + return newClass.indexOf(' ' + cls + ' ') > -1; + } + } + }, { + key: 'addClass', + value: function addClass(cls) { + if (!this.hasClass(cls)) { + this.el.className = this.el.className + ' ' + cls; + } + + return this; + } + }, { + key: 'toggleClass', + value: function toggleClass(cls) { + if (this.hasClass(cls)) { + this.removeClass(cls); + } else { + this.addClass(cls); + } + } + }, { + key: 'html', + value: function html(_html) { + try { + if (typeof _html == 'string') { + this.el.innerHTML = _html; + } else { + this.empty().append(_html); + } + } catch (e) { + console.log(_html); + } + + return this; + } + }, { + key: 'find', + value: function find(selector) { + return this.el.querySelector(selector); + } + }, { + key: '$', + value: function $(selector) { + return new Dom(this.find(selector)); + } + }, { + key: 'findAll', + value: function findAll(selector) { + return this.el.querySelectorAll(selector); + } + }, { + key: '$$', + value: function $$(selector) { + return [].concat(toConsumableArray(this.findAll(selector))).map(function (el) { + return new Dom(el); + }); + } + }, { + key: 'empty', + value: function empty() { + return this.html(''); + } + }, { + key: 'append', + value: function append(el) { + + if (typeof el == 'string') { + this.el.appendChild(document.createTextNode(el)); + } else { + this.el.appendChild(el.el || el); + } + + return this; + } + }, { + key: 'appendTo', + value: function appendTo(target) { + var t = target.el ? target.el : target; + + t.appendChild(this.el); + + return this; + } + }, { + key: 'remove', + value: function remove() { + if (this.el.parentNode) { + this.el.parentNode.removeChild(this.el); + } + + return this; + } + }, { + key: 'text', + value: function text() { + return this.el.textContent; + } + }, { + key: 'css', + value: function css(key, value) { + var _this = this; + + if (arguments.length == 2) { + this.el.style[key] = value; + } else if (arguments.length == 1) { + + if (typeof key == 'string') { + return getComputedStyle(this.el)[key]; + } else { + var keys = key || {}; + Object.keys(keys).forEach(function (k) { + _this.el.style[k] = keys[k]; + }); + } + } + + return this; + } + }, { + key: 'cssFloat', + value: function cssFloat(key) { + return parseFloat(this.css(key)); + } + }, { + key: 'cssInt', + value: function cssInt(key) { + return parseInt(this.css(key)); + } + }, { + key: 'offset', + value: function offset() { + var rect = this.el.getBoundingClientRect(); + + return { + top: rect.top + Dom.getScrollTop(), + left: rect.left + Dom.getScrollLeft() + }; + } + }, { + key: 'rect', + value: function rect() { + return this.el.getBoundingClientRect(); + } + }, { + key: 'position', + value: function position() { + + if (this.el.style.top) { + return { + top: parseFloat(this.css('top')), + left: parseFloat(this.css('left')) + }; + } else { + return this.el.getBoundingClientRect(); + } + } + }, { + key: 'size', + value: function size() { + return [this.width(), this.height()]; + } + }, { + key: 'width', + value: function width() { + return this.el.offsetWidth || this.el.getBoundingClientRect().width; + } + }, { + key: 'contentWidth', + value: function contentWidth() { + return this.width() - this.cssFloat('padding-left') - this.cssFloat('padding-right'); + } + }, { + key: 'height', + value: function height() { + return this.el.offsetHeight || this.el.getBoundingClientRect().height; + } + }, { + key: 'contentHeight', + value: function contentHeight() { + return this.height() - this.cssFloat('padding-top') - this.cssFloat('padding-bottom'); + } + }, { + key: 'dataKey', + value: function dataKey(key) { + return this.uniqId + '.' + key; + } + }, { + key: 'data', + value: function data(key, value) { + if (arguments.length == 2) { + cached[this.dataKey(key)] = value; + } else if (arguments.length == 1) { + return cached[this.dataKey(key)]; + } else { + var keys = Object.keys(cached); + + var uniqId = this.uniqId + "."; + return keys.filter(function (key) { + if (key.indexOf(uniqId) == 0) { + return true; + } + + return false; + }).map(function (value) { + return cached[value]; + }); + } + + return this; + } + }, { + key: 'val', + value: function val(value) { + if (arguments.length == 0) { + return this.el.value; + } else if (arguments.length == 1) { + this.el.value = value; + } + + return this; + } + }, { + key: 'int', + value: function int() { + return parseInt(this.val(), 10); + } + }, { + key: 'float', + value: function float() { + return parseFloat(this.val()); + } + }, { + key: 'show', + value: function show() { + return this.css('display', 'block'); + } + }, { + key: 'hide', + value: function hide() { + return this.css('display', 'none'); + } + }, { + key: 'toggle', + value: function toggle() { + if (this.css('display') == 'none') { + return this.show(); + } else { + return this.hide(); + } + } + }, { + key: 'scrollTop', + value: function scrollTop() { + if (this.el === document.body) { + return Dom.getScrollTop(); + } + + return this.el.scrollTop; + } + }, { + key: 'scrollLeft', + value: function scrollLeft() { + if (this.el === document.body) { + return Dom.getScrollLeft(); + } + + return this.el.scrollLeft; + } + }, { + key: 'on', + value: function on(eventName, callback, opt1, opt2) { + this.el.addEventListener(eventName, callback, opt1, opt2); + + return this; + } + }, { + key: 'off', + value: function off(eventName, callback) { + this.el.removeEventListener(eventName, callback); + + return this; + } + }, { + key: 'getElement', + value: function getElement() { + return this.el; + } + }, { + key: 'createChild', + value: function createChild(tag) { + var className = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ''; + var attrs = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : {}; + var css = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : {}; + + var $element = new Dom(tag, className, attrs); + $element.css(css); + + this.append($element); + + return $element; + } + }, { + key: 'firstChild', + value: function firstChild() { + return new Dom(this.el.firstElementChild); + } + }, { + key: 'replace', + value: function replace(oldElement, newElement) { + this.el.replaceChild(newElement, oldElement); + + return this; + } + }], [{ + key: 'getScrollTop', + value: function getScrollTop() { + return Math.max(window.pageYOffset, document.documentElement.scrollTop, document.body.scrollTop); + } + }, { + key: 'getScrollLeft', + value: function getScrollLeft() { + return Math.max(window.pageXOffset, document.documentElement.scrollLeft, document.body.scrollLeft); + } + }]); + return Dom; +}(); + +var BaseModule = function () { + function BaseModule($store) { + classCallCheck(this, BaseModule); + + this.$store = $store; + this.initialize(); + } + + createClass(BaseModule, [{ + key: 'initialize', + value: function initialize() { + var _this = this; + + this.filterProps().forEach(function (key) { + _this.$store.action(key, _this); + }); + } + }, { + key: 'filterProps', + value: function filterProps() { + var pattern = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : '/'; + + return Object.getOwnPropertyNames(this.__proto__).filter(function (key) { + return key.startsWith(pattern); + }); + } + }]); + return BaseModule; +}(); + +var ColorSetsList = function (_BaseModule) { + inherits(ColorSetsList, _BaseModule); + + function ColorSetsList() { + classCallCheck(this, ColorSetsList); + return possibleConstructorReturn(this, (ColorSetsList.__proto__ || Object.getPrototypeOf(ColorSetsList)).apply(this, arguments)); + } + + createClass(ColorSetsList, [{ + key: 'initialize', + value: function initialize() { + get(ColorSetsList.prototype.__proto__ || Object.getPrototypeOf(ColorSetsList.prototype), 'initialize', this).call(this); + + // set property + this.$store.colorSetsList = [{ name: "Material", + colors: ['#F44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5', '#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50', '#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800', '#FF5722', '#795548', '#9E9E9E', '#607D8B'] + }, { name: "Custom", "edit": true, "colors": [] }, { name: "Color Scale", "scale": ['red', 'yellow', 'black'], count: 5 }]; + this.$store.currentColorSets = {}; + } + }, { + key: '/list', + value: function list($store) { + return Array.isArray($store.userList) && $store.userList.length ? $store.userList : $store.colorSetsList; + } + }, { + key: '/setUserPalette', + value: function setUserPalette($store, list) { + $store.userList = list; + + $store.dispatch('/resetUserPalette'); + $store.dispatch('/setCurrentColorSets'); + } + }, { + key: '/resetUserPalette', + value: function resetUserPalette($store) { + if ($store.userList && $store.userList.length) { + $store.userList = $store.userList.map(function (element, index) { + + if (typeof element.colors == 'function') { + var makeCallback = element.colors; + + element.colors = makeCallback($store); + element._colors = makeCallback; + } + + return Object.assign({ + name: 'color-' + index, + colors: [] + }, element); + }); + + $store.emit('changeUserList'); + } + } + }, { + key: '/setCurrentColorSets', + value: function setCurrentColorSets($store, nameOrIndex) { + + var _list = $store.dispatch('/list'); + + if (typeof nameOrIndex == 'undefined') { + $store.currentColorSets = _list[0]; + } else if (typeof nameOrIndex == 'number') { + $store.currentColorSets = _list[nameOrIndex]; + } else { + $store.currentColorSets = _list.filter(function (obj) { + return obj.name == nameOrIndex; + })[0]; + } + + $store.emit('changeCurrentColorSets'); + } + }, { + key: '/getCurrentColorSets', + value: function getCurrentColorSets($store) { + return $store.currentColorSets; + } + }, { + key: '/addCurrentColor', + value: function addCurrentColor($store, color) { + if (Array.isArray($store.currentColorSets.colors)) { + $store.currentColorSets.colors.push(color); + $store.emit('changeCurrentColorSets'); + } + } + }, { + key: '/setCurrentColorAll', + value: function setCurrentColorAll($store) { + var colors = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : []; + + $store.currentColorSets.colors = colors; + $store.emit('changeCurrentColorSets'); + } + }, { + key: '/removeCurrentColor', + value: function removeCurrentColor($store, index) { + if ($store.currentColorSets.colors[index]) { + $store.currentColorSets.colors.splice(index, 1); + $store.emit('changeCurrentColorSets'); + } + } + }, { + key: '/removeCurrentColorToTheRight', + value: function removeCurrentColorToTheRight($store, index) { + if ($store.currentColorSets.colors[index]) { + $store.currentColorSets.colors.splice(index, Number.MAX_VALUE); + $store.emit('changeCurrentColorSets'); + } + } + }, { + key: '/clearPalette', + value: function clearPalette($store) { + if ($store.currentColorSets.colors) { + $store.currentColorSets.colors = []; + $store.emit('changeCurrentColorSets'); + } + } + }, { + key: '/getCurrentColors', + value: function getCurrentColors($store) { + return $store.dispatch('/getColors', $store.currentColorSets); + } + }, { + key: '/getColors', + value: function getColors($store, element) { + if (element.scale) { + return Color$1.scale(element.scale, element.count); + } + + return element.colors || []; + } + }, { + key: '/getColorSetsList', + value: function getColorSetsList($store) { + return $store.dispatch('/list').map(function (element) { + return { + name: element.name, + edit: element.edit, + colors: $store.dispatch('/getColors', element) + }; + }); + } + }]); + return ColorSetsList; +}(BaseModule); + +var Event = { + addEvent: function addEvent(dom, eventName, callback, options) { + if (dom) { + dom.addEventListener(eventName, callback, options); + } + }, + removeEvent: function removeEvent(dom, eventName, callback) { + if (dom) { + dom.removeEventListener(eventName, callback); + } + }, + pos: function pos(e) { + if (e.touches && e.touches[0]) { + return e.touches[0]; + } + + return e; + }, + posXY: function posXY(e) { + var pos = this.pos(e); + return { + x: pos.pageX, + y: pos.pageY + }; + } +}; + +var DELEGATE_SPLIT = '.'; + +var State = function () { + function State(masterObj) { + var settingObj = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {}; + classCallCheck(this, State); + + + this.masterObj = masterObj; + this.settingObj = settingObj; + } + + createClass(State, [{ + key: 'set', + value: function set$$1(key, value) { + var defaultValue = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : undefined; + + this.settingObj[key] = value || defaultValue; + } + }, { + key: 'init', + value: function init(key) { + + if (!this.has(key)) { + + var arr = key.split(DELEGATE_SPLIT); + + var obj = this.masterObj.refs[arr[0]] || this.masterObj[arr[0]] || this.masterObj; + var method = arr.pop(); + + if (obj[method]) { + for (var _len = arguments.length, args = Array(_len > 1 ? _len - 1 : 0), _key = 1; _key < _len; _key++) { + args[_key - 1] = arguments[_key]; + } + + var value = obj[method].apply(obj, args); + + this.set(key, value); + } + } + } + }, { + key: 'get', + value: function get$$1(key) { + var defaultValue = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : ''; + + + this.init(key, defaultValue); + + return this.settingObj[key] || defaultValue; + } + }, { + key: 'has', + value: function has(key) { + return !!this.settingObj[key]; + } + }]); + return State; +}(); + +var CHECK_EVENT_PATTERN = /^(click|mouse(down|up|move|enter|leave)|touch(start|move|end)|key(down|up|press)|contextmenu|change|input)/ig; +var CHECK_LOAD_PATTERN = /^load (.*)/ig; +var EVENT_SAPARATOR = ' '; +var META_KEYS = ['Control', 'Shift', 'Alt', 'Meta']; + +var EventMachin = function () { + function EventMachin() { + classCallCheck(this, EventMachin); + + this.state = new State(this); + this.refs = {}; + + this.childComponents = this.components(); + } + + /** + * 자식으로 사용할 컴포넌트를 생성해준다. + * 생성 시점에 $store 객체가 자동으로 공유된다. + * 모든 데이타는 $store 기준으로 작성한다. + */ + + + createClass(EventMachin, [{ + key: 'newChildComponents', + value: function newChildComponents() { + var _this = this; + + var childKeys = Object.keys(this.childComponents); + childKeys.forEach(function (key) { + var Component = _this.childComponents[key]; + + _this[key] = new Component(_this); + }); + } + + /** + * 부모가 정의한 template 과 그 안에서 동작하는 자식 컴포넌트들을 다 합쳐서 + * 최종 element 를 만들어준다. + * + * 그리고 자동으로 load 되어질게 있으면 로드 해준다. + */ + + }, { + key: 'render', + value: function render() { + // 1. 나의 template 을 만들어내고 + this.$el = this.parseTemplate(this.template()); + this.refs.$el = this.$el; + + // 개별 객체 셋팅하고 + this.parseTarget(); + + // 데이타 로드 하고 + this.load(); + + this.afterRender(); + } + }, { + key: 'afterRender', + value: function afterRender() {} + + /** + * 자식 컴포넌트로 사용될 객체 정의 + */ + + }, { + key: 'components', + value: function components() { + return {}; + } + + /** + * Class 기반으로 $el 을 생성하기 위해서 + * 선언형으로 html 템플릿을 정의한다. + * + * @param {*} html + */ + + }, { + key: 'parseTemplate', + value: function parseTemplate(html) { + var _this2 = this; + + var $el = new Dom("div").html(html).firstChild(); + + // ref element 정리 + var refs = $el.findAll('[ref]'); + + [].concat(toConsumableArray(refs)).forEach(function (node) { + var name = node.getAttribute('ref'); + _this2.refs[name] = new Dom(node); + }); + + return $el; + } + + /** + * target 으로 지정된 자식 컴포넌트를 대체해준다. + */ + + }, { + key: 'parseTarget', + value: function parseTarget() { + var _this3 = this; + + var $el = this.$el; + var targets = $el.findAll('[target]'); + + [].concat(toConsumableArray(targets)).forEach(function (node) { + var targetComponentName = node.getAttribute('target'); + var refName = node.getAttribute('ref') || targetComponentName; + + var Component = _this3.childComponents[targetComponentName]; + var instance = new Component(_this3); + _this3[refName] = instance; + _this3.refs[refName] = instance.$el; + + if (instance) { + instance.render(); + var $parent = new Dom(node.parentNode); + $parent.replace(node, instance.$el.el); + } + }); + } + + // load function이 정의된 객체는 load 를 실행해준다. + + }, { + key: 'load', + value: function load() { + var _this4 = this; + + this.filterProps(CHECK_LOAD_PATTERN).forEach(function (callbackName) { + var elName = callbackName.split('load ')[1]; + + if (_this4.refs[elName]) { + _this4.refs[elName].html(_this4.parseTemplate(_this4[callbackName].call(_this4))); + } + }); + } + + // 기본 템플릿 지정 + + }, { + key: 'template', + value: function template() { + return '
        '; + } + }, { + key: 'initialize', + value: function initialize() {} + + /** + * 이벤트를 초기화한다. + */ + + }, { + key: 'initializeEvent', + value: function initializeEvent() { + var _this5 = this; + + this.initializeEventMachin(); + + // 자식 이벤트도 같이 초기화 한다. + // 그래서 이 메소드는 부모에서 한번만 불려도 된다. + Object.keys(this.childComponents).forEach(function (key) { + if (_this5[key]) _this5[key].initializeEvent(); + }); + } + + /** + * 자원을 해제한다. + * 이것도 역시 자식 컴포넌트까지 제어하기 때문에 가장 최상위 부모에서 한번만 호출되도 된다. + */ + + }, { + key: 'destroy', + value: function destroy() { + var _this6 = this; + + this.destroyEventMachin(); + // this.refs = {} + + Object.keys(this.childComponents).forEach(function (key) { + if (_this6[key]) _this6[key].destroy(); + }); + } + }, { + key: 'destroyEventMachin', + value: function destroyEventMachin() { + this.removeEventAll(); + } + }, { + key: 'initializeEventMachin', + value: function initializeEventMachin() { + this.filterProps(CHECK_EVENT_PATTERN).forEach(this.parseEvent.bind(this)); + } + + /** + * property 수집하기 + * 상위 클래스의 모든 property 를 수집해서 리턴한다. + */ + + }, { + key: 'collectProps', + value: function collectProps() { + + if (!this.collapsedProps) { + var p = this.__proto__; + var results = []; + do { + results.push.apply(results, toConsumableArray(Object.getOwnPropertyNames(p))); + p = p.__proto__; + } while (p); + + this.collapsedProps = results; + } + + return this.collapsedProps; + } + }, { + key: 'filterProps', + value: function filterProps(pattern) { + return this.collectProps().filter(function (key) { + return key.match(pattern); + }); + } + }, { + key: 'parseEvent', + value: function parseEvent(key) { + var arr = key.split(EVENT_SAPARATOR); + + this.bindingEvent(arr, this[key].bind(this)); + } + }, { + key: 'getDefaultDomElement', + value: function getDefaultDomElement(dom) { + var el = void 0; + + if (dom) { + el = this.refs[dom] || this[dom] || window[dom]; + } else { + el = this.el || this.$el || this.$root; + } + + if (el instanceof Dom) { + return el.getElement(); + } + + return el; + } + }, { + key: 'getDefaultEventObject', + value: function getDefaultEventObject(eventName) { + var _this7 = this; + + var arr = eventName.split('.'); + var realEventName = arr.shift(); + + var isControl = arr.includes('Control'); + var isShift = arr.includes('Shift'); + var isAlt = arr.includes('Alt'); + var isMeta = arr.includes('Meta'); + + arr = arr.filter(function (code) { + return META_KEYS.includes(code) === false; + }); + + var checkMethodList = arr.filter(function (code) { + return !!_this7[code]; + }); + + arr = arr.filter(function (code) { + return checkMethodList.includes(code) === false; + }).map(function (code) { + return code.toLowerCase(); + }); + + return { + eventName: realEventName, + isControl: isControl, + isShift: isShift, + isAlt: isAlt, + isMeta: isMeta, + codes: arr, + checkMethodList: checkMethodList + }; + } + }, { + key: 'bindingEvent', + value: function bindingEvent(_ref, callback) { + var _ref2 = toArray(_ref), + eventName = _ref2[0], + dom = _ref2[1], + delegate = _ref2.slice(2); + + dom = this.getDefaultDomElement(dom); + var eventObject = this.getDefaultEventObject(eventName); + + eventObject.dom = dom; + eventObject.delegate = delegate.join(EVENT_SAPARATOR); + + this.addEvent(eventObject, callback); + } + }, { + key: 'matchPath', + value: function matchPath(el, selector) { + if (el) { + if (el.matches(selector)) { + return el; + } + return this.matchPath(el.parentElement, selector); + } + return null; + } + }, { + key: 'getBindings', + value: function getBindings() { + + if (!this._bindings) { + this.initBindings(); + } + + return this._bindings; + } + }, { + key: 'addBinding', + value: function addBinding(obj) { + this.getBindings().push(obj); + } + }, { + key: 'initBindings', + value: function initBindings() { + this._bindings = []; + } + }, { + key: 'checkEventType', + value: function checkEventType(e, eventObject) { + var _this8 = this; + + var onlyControl = eventObject.isControl ? e.ctrlKey : true; + var onlyShift = eventObject.isShift ? e.shiftKey : true; + var onlyAlt = eventObject.isAlt ? e.altKey : true; + var onlyMeta = eventObject.isMeta ? e.metaKey : true; + + var hasKeyCode = true; + if (eventObject.codes.length) { + hasKeyCode = eventObject.codes.includes(e.code.toLowerCase()) || eventObject.codes.includes(e.key.toLowerCase()); + } + + var isAllCheck = true; + if (eventObject.checkMethodList.length) { + // 체크 메소드들은 모든 메소드를 다 적용해야한다. + isAllCheck = eventObject.checkMethodList.every(function (method) { + return _this8[method].call(_this8, e); + }); + } + + return onlyControl && onlyAlt && onlyShift && onlyMeta && hasKeyCode && isAllCheck; + } + }, { + key: 'makeCallback', + value: function makeCallback(eventObject, callback) { + var _this9 = this; + + if (eventObject.delegate) { + return function (e) { + e.xy = Event.posXY(e); + if (_this9.checkEventType(e, eventObject)) { + var delegateTarget = _this9.matchPath(e.target || e.srcElement, eventObject.delegate); + + if (delegateTarget) { + // delegate target 이 있는 경우만 callback 실행 + e.delegateTarget = delegateTarget; + e.$delegateTarget = new Dom(delegateTarget); + return callback(e); + } + } + }; + } else { + return function (e) { + e.xy = Event.posXY(e); + if (_this9.checkEventType(e, eventObject)) { + return callback(e); + } + }; + } + } + }, { + key: 'addEvent', + value: function addEvent(eventObject, callback) { + eventObject.callback = this.makeCallback(eventObject, callback); + this.addBinding(eventObject); + + var options = true; + if (eventObject.eventName === 'touchstart') { + options = { passive: true }; + } + + Event.addEvent(eventObject.dom, eventObject.eventName, eventObject.callback, options); + } + }, { + key: 'removeEventAll', + value: function removeEventAll() { + var _this10 = this; + + this.getBindings().forEach(function (obj) { + _this10.removeEvent(obj); + }); + this.initBindings(); + } + }, { + key: 'removeEvent', + value: function removeEvent(_ref3) { + var eventName = _ref3.eventName, + dom = _ref3.dom, + callback = _ref3.callback; + + Event.removeEvent(dom, eventName, callback); + } + }]); + return EventMachin; +}(); + +var CHECK_STORE_EVENT_PATTERN = /^@/; + +var UIElement = function (_EventMachin) { + inherits(UIElement, _EventMachin); + + function UIElement(opt) { + classCallCheck(this, UIElement); + + var _this = possibleConstructorReturn(this, (UIElement.__proto__ || Object.getPrototypeOf(UIElement)).call(this, opt)); + + _this.opt = opt || {}; + + if (opt && opt.$store) { + _this.$store = opt.$store; + } + + _this.initialize(); + + _this.initializeStoreEvent(); + return _this; + } + + /** + * initialize store event + * + * you can define '@xxx' method(event) in UIElement + * + * + */ + + + createClass(UIElement, [{ + key: 'initializeStoreEvent', + value: function initializeStoreEvent() { + var _this2 = this; + + this.storeEvents = {}; + this.filterProps(CHECK_STORE_EVENT_PATTERN).forEach(function (key) { + var arr = key.split('@'); + arr.shift(); + var event = arr.join('@'); + + _this2.storeEvents[event] = _this2[key].bind(_this2); + _this2.$store.on(event, _this2.storeEvents[event]); + }); + } + }, { + key: 'destoryStoreEvent', + value: function destoryStoreEvent() { + var _this3 = this; + + Object.keys(this.storeEvents).forEach(function (event) { + _this3.$store.off(event, _this3.storeEvents[event]); + }); + } + }]); + return UIElement; +}(EventMachin); + +function isUndefined(v) { + return typeof v == 'undefined' || v == null; +} + +var ColorManager = function (_BaseModule) { + inherits(ColorManager, _BaseModule); + + function ColorManager() { + classCallCheck(this, ColorManager); + return possibleConstructorReturn(this, (ColorManager.__proto__ || Object.getPrototypeOf(ColorManager)).apply(this, arguments)); + } + + createClass(ColorManager, [{ + key: 'initialize', + value: function initialize() { + get(ColorManager.prototype.__proto__ || Object.getPrototypeOf(ColorManager.prototype), 'initialize', this).call(this); + + this.$store.rgb = {}; + this.$store.hsl = {}; + this.$store.hsv = {}; + this.$store.alpha = 1; + this.$store.format = 'hex'; + + // this.$store.dispatch('/changeColor'); + } + }, { + key: '/changeFormat', + value: function changeFormat($store, format) { + $store.format = format; + + $store.emit('changeFormat'); + } + }, { + key: '/initColor', + value: function initColor($store, colorObj, source) { + $store.dispatch('/changeColor', colorObj, source, true); + $store.emit('initColor'); + } + }, { + key: '/changeColor', + value: function changeColor($store, colorObj, source, isNotEmit) { + + colorObj = colorObj || '#FF0000'; + + if (typeof colorObj == 'string') { + colorObj = Color$1.parse(colorObj); + } + + colorObj.source = colorObj.source || source; + + $store.alpha = isUndefined(colorObj.a) ? $store.alpha : colorObj.a; + $store.format = colorObj.type != 'hsv' ? colorObj.type || $store.format : $store.format; + + if (colorObj.type == 'hsl') { + $store.hsl = Object.assign($store.hsl, colorObj); + $store.rgb = Color$1.HSLtoRGB($store.hsl); + $store.hsv = Color$1.HSLtoHSV(colorObj); + } else if (colorObj.type == 'hex') { + $store.rgb = Object.assign($store.rgb, colorObj); + $store.hsl = Color$1.RGBtoHSL($store.rgb); + $store.hsv = Color$1.RGBtoHSV(colorObj); + } else if (colorObj.type == 'rgb') { + $store.rgb = Object.assign($store.rgb, colorObj); + $store.hsl = Color$1.RGBtoHSL($store.rgb); + $store.hsv = Color$1.RGBtoHSV(colorObj); + } else if (colorObj.type == 'hsv') { + $store.hsv = Object.assign($store.hsv, colorObj); + $store.rgb = Color$1.HSVtoRGB($store.hsv); + $store.hsl = Color$1.HSVtoHSL($store.hsv); + } + + if (!isNotEmit) { + $store.emit('changeColor', colorObj.source); + } + } + }, { + key: '/getHueColor', + value: function getHueColor($store) { + return HueColor.checkHueColor($store.hsv.h / 360); + } + }, { + key: '/toString', + value: function toString($store, type) { + type = type || $store.format; + var colorObj = $store[type] || $store.rgb; + return Color$1.format(_extends({}, colorObj, { + a: $store.alpha + }), type); + } + }, { + key: '/toColor', + value: function toColor($store, type) { + type = type || $store.format; + + if (type == 'rgb') { + return $store.dispatch('/toRGB'); + } else if (type == 'hsl') { + return $store.dispatch('/toHSL'); + } else if (type == 'hex') { + return $store.dispatch('/toHEX'); + } + + return $store.dispatch('/toString', type); + } + }, { + key: '/toRGB', + value: function toRGB($store) { + return $store.dispatch('/toString', 'rgb'); + } + }, { + key: '/toHSL', + value: function toHSL($store) { + return $store.dispatch('/toString', 'hsl'); + } + }, { + key: '/toHEX', + value: function toHEX($store) { + return $store.dispatch('/toString', 'hex').toUpperCase(); + } + }]); + return ColorManager; +}(BaseModule); + +var BaseStore = function () { + function BaseStore(opt) { + classCallCheck(this, BaseStore); + + this.callbacks = []; + this.actions = []; + this.modules = opt.modules || []; + + this.initialize(); + } + + createClass(BaseStore, [{ + key: 'initialize', + value: function initialize() { + this.initializeModule(); + } + }, { + key: 'initializeModule', + value: function initializeModule() { + var _this = this; + + this.modules.forEach(function (Module) { + var instance = new Module(_this); + }); + } + }, { + key: 'action', + value: function action(_action, context) { + this.actions[_action] = { context: context, callback: context[_action] }; + } + }, { + key: 'dispatch', + value: function dispatch(action) { + var args = [].concat(Array.prototype.slice.call(arguments)); + var action = args.shift(); + + var m = this.actions[action]; + + if (m) { + return m.callback.apply(m.context, [this].concat(toConsumableArray(args))); + } + } + }, { + key: 'module', + value: function module(ModuleObject) { + // this.action() + } + }, { + key: 'on', + value: function on(event, callback) { + this.callbacks.push({ event: event, callback: callback }); + } + }, { + key: 'off', + value: function off(event, callback) { + + if (arguments.length == 0) { + this.callbacks = []; + } else if (arguments.length == 1) { + this.callbacks = this.callbacks.filter(function (f) { + return f.event != event; + }); + } else if (arguments.length == 2) { + this.callbacks = this.callbacks.filter(function (f) { + return f.event != event && f.callback != callback; + }); + } + } + }, { + key: 'emit', + value: function emit() { + var args = [].concat(Array.prototype.slice.call(arguments)); + var event = args.shift(); + + this.callbacks.filter(function (f) { + return f.event == event; + }).forEach(function (f) { + if (f && typeof f.callback == 'function') { + f.callback.apply(f, toConsumableArray(args)); + } + }); + } + }]); + return BaseStore; +}(); + +var BaseColorPicker = function (_UIElement) { + inherits(BaseColorPicker, _UIElement); + + function BaseColorPicker(opt) { + classCallCheck(this, BaseColorPicker); + + var _this = possibleConstructorReturn(this, (BaseColorPicker.__proto__ || Object.getPrototypeOf(BaseColorPicker)).call(this, opt)); + + _this.isColorPickerShow = false; + _this.isShortCut = false; + _this.hideDelay = +(typeof _this.opt.hideDeplay == 'undefined' ? 2000 : _this.opt.hideDelay); + _this.timerCloseColorPicker; + _this.autoHide = _this.opt.autoHide || true; + _this.outputFormat = _this.opt.outputFormat; + _this.$checkColorPickerClass = _this.checkColorPickerClass.bind(_this); + + return _this; + } + + createClass(BaseColorPicker, [{ + key: 'initialize', + value: function initialize() { + var _this2 = this; + + this.$body = null; + this.$root = null; + + this.$store = new BaseStore({ + modules: [ColorManager, ColorSetsList] + }); + + this.callbackChange = function () { + _this2.callbackColorValue(); + }; + + this.callbackLastUpdate = function () { + _this2.callbackLastUpdateColorValue(); + }; + + this.colorpickerShowCallback = function () {}; + this.colorpickerHideCallback = function () {}; + this.colorpickerLastUpdateCallback = function () {}; + + this.$body = new Dom(this.getContainer()); + this.$root = new Dom('div', 'codemirror-colorpicker'); + + // append colorpicker to container (ex : body) + if (this.opt.position == 'inline') { + this.$body.append(this.$root); + } + + if (this.opt.type) { + // to change css style + this.$root.addClass(this.opt.type); + } + + if (this.opt.hideInformation) { + this.$root.addClass('hide-information'); + } + + if (this.opt.hideColorsets) { + this.$root.addClass('hide-colorsets'); + } + + this.$arrow = new Dom('div', 'arrow'); + + this.$root.append(this.$arrow); + + this.$store.dispatch('/setUserPalette', this.opt.colorSets); + + this.render(); + + this.$root.append(this.$el); + + this.initColorWithoutChangeEvent(this.opt.color); + + // 이벤트 연결 + this.initializeEvent(); + } + }, { + key: 'initColorWithoutChangeEvent', + value: function initColorWithoutChangeEvent(color) { + this.$store.dispatch('/initColor', color); + } + + /** + * public method + * + */ + + /** + * + * show colorpicker with position + * + * @param {{left, top, hideDelay, isShortCut}} opt + * @param {String|Object} color + * @param {Function} showCallback it is called when colorpicker is shown + * @param {Function} hideCallback it is called once when colorpicker is hidden + */ + + }, { + key: 'show', + value: function show(opt, color, showCallback, hideCallback, lastUpdateCallback) { + + // 매번 이벤트를 지우고 다시 생성할 필요가 없어서 초기화 코드는 지움. + // this.destroy(); + // this.initializeEvent(); + // define colorpicker callback + this.colorpickerShowCallback = showCallback; + this.colorpickerHideCallback = hideCallback; + this.colorpickerLastUpdateCallback = lastUpdateCallback; + this.$root.css(this.getInitalizePosition()).show(); + + this.isColorPickerShow = true; + this.isShortCut = opt.isShortCut || false; + this.outputFormat = opt.outputFormat; + + // define hide delay + this.hideDelay = +(typeof opt.hideDelay == 'undefined' ? 2000 : opt.hideDelay); + if (this.hideDelay > 0) { + this.setHideDelay(this.hideDelay); + } + + this.$root.appendTo(this.$body); + this.definePosition(opt); + this.initColorWithoutChangeEvent(color); + } + + /** + * + * initialize color for colorpicker + * + * @param {String|Object} newColor + * @param {String} format hex, rgb, hsl + */ + + }, { + key: 'initColor', + value: function initColor(newColor, format) { + this.$store.dispatch('/changeColor', newColor, format); + } + + /** + * hide colorpicker + * + */ + + }, { + key: 'hide', + value: function hide() { + if (this.isColorPickerShow) { + // this.destroy(); + this.$root.hide(); + this.$root.remove(); // not empty + this.isColorPickerShow = false; + + this.callbackHideColorValue(); + } + } + + /** + * set to colors in current sets that you see + * @param {Array} colors + */ + + }, { + key: 'setColorsInPalette', + value: function setColorsInPalette() { + var colors = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; + + this.$store.dispatch('/setCurrentColorAll', colors); + } + + /** + * refresh all color palette + * + * @param {*} list + */ + + }, { + key: 'setUserPalette', + value: function setUserPalette() { + var list = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : []; + + this.$store.dispatch('/setUserPalette', list); + } + + /** + * private method + */ + + }, { + key: 'getOption', + value: function getOption(key) { + return this.opt[key]; + } + }, { + key: 'setOption', + value: function setOption(key, value) { + this.opt[key] = value; + } + }, { + key: 'isType', + value: function isType(key) { + return this.getOption('type') == key; + } + }, { + key: 'isPaletteType', + value: function isPaletteType() { + return this.isType('palette'); + } + }, { + key: 'isSketchType', + value: function isSketchType() { + return this.isType('sketch'); + } + }, { + key: 'getContainer', + value: function getContainer() { + return this.opt.container || document.body; + } + }, { + key: 'getColor', + value: function getColor(type) { + return this.$store.dispatch('/toColor', type); + } + }, { + key: 'definePositionForArrow', + value: function definePositionForArrow(opt, elementScreenLeft, elementScreenTop) { + // console.log(arguments) + } + }, { + key: 'definePosition', + value: function definePosition(opt) { + + var width = this.$root.width(); + var height = this.$root.height(); + + // set left position for color picker + var elementScreenLeft = opt.left - this.$body.scrollLeft(); + if (width + elementScreenLeft > window.innerWidth) { + elementScreenLeft -= width + elementScreenLeft - window.innerWidth; + } + if (elementScreenLeft < 0) { + elementScreenLeft = 0; + } + + // set top position for color picker + var elementScreenTop = opt.top - this.$body.scrollTop(); + if (height + elementScreenTop > window.innerHeight) { + elementScreenTop -= height + elementScreenTop - window.innerHeight; + } + if (elementScreenTop < 0) { + elementScreenTop = 0; + } + + // set position + this.$root.css({ + left: elementScreenLeft + 'px', + top: elementScreenTop + 'px' + }); + + // this.definePositionForArrow(opt, elementScreenLeft, elementScreenTop); + } + }, { + key: 'getInitalizePosition', + value: function getInitalizePosition() { + if (this.opt.position == 'inline') { + return { + position: 'relative', + left: 'auto', + top: 'auto', + display: 'inline-block' + }; + } else { + return { + position: 'fixed', // color picker has fixed position + left: '-10000px', + top: '-10000px' + }; + } + } + }, { + key: 'isAbsolute', + value: function isAbsolute() { + return this.opt.position !== 'inline'; + } + + // Event Bindings + + }, { + key: 'mouseup.isAbsolute document', + value: function mouseupIsAbsoluteDocument(e) { + + this.__isMouseDown = false; + // when color picker clicked in outside + if (this.checkInHtml(e.target)) { + //this.setHideDelay(hideDelay); + } else if (this.checkColorPickerClass(e.target) == false) { + this.hide(); + } else { + if (!this.__isMouseIn) { + clearTimeout(this.timerCloseColorPicker); + this.timerCloseColorPicker = setTimeout(this.hide.bind(this), this.delayTime || this.hideDelay); + } + } + } + }, { + key: 'keyup.isAbsolute.escape $root', + value: function keyupIsAbsoluteEscape$root(e) { + this.hide(); + } + }, { + key: 'mouseover.isAbsolute $root', + value: function mouseoverIsAbsolute$root(e) { + clearTimeout(this.timerCloseColorPicker); + // this.__isMouseDown = true; + } + }, { + key: 'mousemove.isAbsolute $root', + value: function mousemoveIsAbsolute$root(e) { + clearTimeout(this.timerCloseColorPicker); + } + }, { + key: 'mouseenter.isAbsolute $root', + value: function mouseenterIsAbsolute$root(e) { + clearTimeout(this.timerCloseColorPicker); + this.__isMouseIn = true; + } + }, { + key: 'mouseleave.isAbsolute $root', + value: function mouseleaveIsAbsolute$root(e) { + this.__isMouseIn = false; + if (!this.__isMouseDown) { + clearTimeout(this.timerCloseColorPicker); + this.timerCloseColorPicker = setTimeout(this.hide.bind(this), this.delayTime || this.hideDelay); + } + } + }, { + key: 'mousedown.isAbsolute $root', + value: function mousedownIsAbsolute$root(e) { + this.__isMouseDown = true; + } + }, { + key: 'setHideDelay', + value: function setHideDelay(delayTime) { + this.delayTime = delayTime || 0; + } + }, { + key: 'runHideDelay', + value: function runHideDelay() { + + if (this.isColorPickerShow) { + this.setHideDelay(); + // const hideCallback = this.setHideDelay(delayTime); + + // this.timerCloseColorPicker = setTimeout(hideCallback, delayTime); + } + } + }, { + key: 'callbackColorValue', + value: function callbackColorValue(color) { + color = color || this.getCurrentColor(); + + if (typeof this.opt.onChange == 'function') { + this.opt.onChange.call(this, color); + } + + if (typeof this.colorpickerShowCallback == 'function') { + this.colorpickerShowCallback(color); + } + } + }, { + key: 'callbackLastUpdateColorValue', + value: function callbackLastUpdateColorValue(color) { + color = color || this.getCurrentColor(); + + if (typeof this.opt.onLastUpdate == 'function') { + this.opt.onLastUpdate.call(this, color); + } + + if (typeof this.colorpickerLastUpdateCallback == 'function') { + this.colorpickerLastUpdateCallback(color); + } + } + }, { + key: 'callbackHideColorValue', + value: function callbackHideColorValue(color) { + color = color || this.getCurrentColor(); + if (typeof this.opt.onHide == 'function') { + this.opt.onHide.call(this, color); + } + + if (typeof this.colorpickerHideCallback == 'function') { + this.colorpickerHideCallback(color); + } + } + }, { + key: 'getCurrentColor', + value: function getCurrentColor() { + return this.$store.dispatch('/toColor', this.outputFormat); + } + }, { + key: 'checkColorPickerClass', + value: function checkColorPickerClass(el) { + var hasColorView = new Dom(el).closest('codemirror-colorview'); + var hasColorPicker = new Dom(el).closest('codemirror-colorpicker'); + var hasCodeMirror = new Dom(el).closest('CodeMirror'); + var IsInHtml = el.nodeName == 'HTML'; + + return !!(hasColorPicker || hasColorView || hasCodeMirror); + } + }, { + key: 'checkInHtml', + value: function checkInHtml(el) { + var IsInHtml = el.nodeName == 'HTML'; + + return IsInHtml; + } + }, { + key: 'initializeStoreEvent', + value: function initializeStoreEvent() { + get(BaseColorPicker.prototype.__proto__ || Object.getPrototypeOf(BaseColorPicker.prototype), 'initializeStoreEvent', this).call(this); + + this.$store.on('changeColor', this.callbackChange); + this.$store.on('lastUpdateColor', this.callbackLastUpdate); + this.$store.on('changeFormat', this.callbackChange); + } + }, { + key: 'destroy', + value: function destroy() { + get(BaseColorPicker.prototype.__proto__ || Object.getPrototypeOf(BaseColorPicker.prototype), 'destroy', this).call(this); + + this.$store.off('changeColor', this.callbackChange); + this.$store.off('lastUpdateColor', this.callbackLastUpdate); + this.$store.off('changeFormat', this.callbackChange); + + this.callbackChange = undefined; + this.callbackLastUpdate = undefined; + + // remove color picker callback + this.colorpickerShowCallback = undefined; + this.colorpickerHideCallback = undefined; + } + }]); + return BaseColorPicker; +}(UIElement); + +var BaseBox = function (_UIElement) { + inherits(BaseBox, _UIElement); + + function BaseBox(opt) { + classCallCheck(this, BaseBox); + + var _this = possibleConstructorReturn(this, (BaseBox.__proto__ || Object.getPrototypeOf(BaseBox)).call(this, opt)); + + _this.source = 'base-box'; + return _this; + } + + createClass(BaseBox, [{ + key: 'refresh', + value: function refresh() {} + }, { + key: 'refreshColorUI', + value: function refreshColorUI(e) {} + + /** push change event */ + + }, { + key: 'changeColor', + value: function changeColor(opt) { + this.$store.dispatch('/changeColor', Object.assign({ + source: this.source + }, opt || {})); + } + + // Event Bindings + + }, { + key: 'mouseup document', + value: function mouseupDocument(e) { + this.onDragEnd(e); + } + }, { + key: 'mousemove document', + value: function mousemoveDocument(e) { + this.onDragMove(e); + } + }, { + key: 'mousedown $bar', + value: function mousedown$bar(e) { + e.preventDefault(); + this.isDown = true; + } + }, { + key: 'mousedown $container', + value: function mousedown$container(e) { + this.isDown = true; + this.onDragStart(e); + } + }, { + key: 'touchend document', + value: function touchendDocument(e) { + this.onDragEnd(e); + } + }, { + key: 'touchmove document', + value: function touchmoveDocument(e) { + this.onDragMove(e); + } + }, { + key: 'touchstart $bar', + value: function touchstart$bar(e) { + e.preventDefault(); + this.isDown = true; + } + }, { + key: 'touchstart $container', + value: function touchstart$container(e) { + this.onDragStart(e); + } + }, { + key: 'onDragStart', + value: function onDragStart(e) { + this.isDown = true; + this.refreshColorUI(e); + } + }, { + key: 'onDragMove', + value: function onDragMove(e) { + if (this.isDown) { + this.refreshColorUI(e); + } + } + + /* called when mouse is ended move */ + + }, { + key: 'onDragEnd', + value: function onDragEnd(e) { + if (this.isDown) { + this.$store.emit('lastUpdateColor'); + this.isDown = false; + } + } + }, { + key: '@changeColor', + value: function changeColor(sourceType) { + if (this.source != sourceType) { + this.refresh(); + } + } + }, { + key: '@initColor', + value: function initColor() { + this.refresh(); + } + }]); + return BaseBox; +}(UIElement); + +var BaseSlider = function (_BaseBox) { + inherits(BaseSlider, _BaseBox); + + function BaseSlider(opt) { + classCallCheck(this, BaseSlider); + + var _this = possibleConstructorReturn(this, (BaseSlider.__proto__ || Object.getPrototypeOf(BaseSlider)).call(this, opt)); + + _this.minValue = 0; // min domain value + _this.maxValue = 1; // max domain value + _this.source = 'base-slider'; + return _this; + } + + /* slider container's min and max position */ + + + createClass(BaseSlider, [{ + key: 'getMinMaxPosition', + value: function getMinMaxPosition() { + var min = this.getMinPosition(); + var width = this.getMaxDist(); + var max = min + width; + + return { min: min, max: max, width: width }; + } + + /** get current position on page */ + + }, { + key: 'getCurrent', + value: function getCurrent(value) { + return min + this.getMaxDist() * value; + } + + /** get min position on slider container */ + + }, { + key: 'getMinPosition', + value: function getMinPosition() { + return this.refs.$container.offset().left; + } + }, { + key: 'getMaxDist', + value: function getMaxDist() { + return this.state.get('$container.width'); + } + + /** get dist for position value */ + + }, { + key: 'getDist', + value: function getDist(current) { + var _getMinMaxPosition = this.getMinMaxPosition(), + min = _getMinMaxPosition.min, + max = _getMinMaxPosition.max; + + var dist; + if (current < min) { + dist = 0; + } else if (current > max) { + dist = 100; + } else { + dist = (current - min) / (max - min) * 100; + } + + return dist; + } + + /** get caculated dist for domain value */ + + }, { + key: 'getCaculatedDist', + value: function getCaculatedDist(e) { + var current = e ? this.getMousePosition(e) : this.getCurrent(this.getDefaultValue() / this.maxValue); + var dist = this.getDist(current); + + return dist; + } + + /** get default value used in slider container */ + + }, { + key: 'getDefaultValue', + value: function getDefaultValue() { + return 0; + } + + /** set mosue position */ + + }, { + key: 'setMousePosition', + value: function setMousePosition(x) { + this.refs.$bar.css({ left: x + 'px' }); + } + + /** set mouse position in page */ + + }, { + key: 'getMousePosition', + value: function getMousePosition(e) { + return Event.pos(e).pageX; + } + }, { + key: 'refresh', + value: function refresh() { + this.setColorUI(); + } + + /** set drag bar position */ + + }, { + key: 'setColorUI', + value: function setColorUI(v) { + + v = v || this.getDefaultValue(); + + if (v <= this.minValue) { + this.refs.$bar.addClass('first').removeClass('last'); + } else if (v >= this.maxValue) { + this.refs.$bar.addClass('last').removeClass('first'); + } else { + this.refs.$bar.removeClass('last').removeClass('first'); + } + + this.setMousePosition(this.getMaxDist() * ((v || 0) / this.maxValue)); + } + }]); + return BaseSlider; +}(BaseBox); + +var Value = function (_BaseSlider) { + inherits(Value, _BaseSlider); + + function Value(opt) { + classCallCheck(this, Value); + + var _this = possibleConstructorReturn(this, (Value.__proto__ || Object.getPrototypeOf(Value)).call(this, opt)); + + _this.minValue = 0; + _this.maxValue = 1; + _this.source = 'value-control'; + return _this; + } + + createClass(Value, [{ + key: 'template', + value: function template() { + return '\n
        \n
        \n
        \n
        \n
        \n '; + } + }, { + key: 'setBackgroundColor', + value: function setBackgroundColor() { + this.refs.$container.css("background-color", this.$store.dispatch('/toRGB')); + } + }, { + key: 'refresh', + value: function refresh() { + get(Value.prototype.__proto__ || Object.getPrototypeOf(Value.prototype), 'refresh', this).call(this); + this.setBackgroundColor(); + } + }, { + key: 'getDefaultValue', + value: function getDefaultValue() { + return this.$store.hsv.v; + } + }, { + key: 'refreshColorUI', + value: function refreshColorUI(e) { + var dist = this.getCaculatedDist(e); + + this.setColorUI(dist / 100 * this.maxValue); + + this.changeColor({ + type: 'hsv', + v: dist / 100 * this.maxValue + }); + } + }]); + return Value; +}(BaseSlider); + +var Opacity = function (_BaseSlider) { + inherits(Opacity, _BaseSlider); + + function Opacity(opt) { + classCallCheck(this, Opacity); + + var _this = possibleConstructorReturn(this, (Opacity.__proto__ || Object.getPrototypeOf(Opacity)).call(this, opt)); + + _this.minValue = 0; + _this.maxValue = 1; + _this.source = 'opacity-control'; + return _this; + } + + createClass(Opacity, [{ + key: 'template', + value: function template() { + return '\n
        \n
        \n
        \n
        \n
        \n
        \n '; + } + }, { + key: 'refresh', + value: function refresh() { + get(Opacity.prototype.__proto__ || Object.getPrototypeOf(Opacity.prototype), 'refresh', this).call(this); + this.setOpacityColorBar(); + } + }, { + key: 'setOpacityColorBar', + value: function setOpacityColorBar() { + var rgb = Object.assign({}, this.$store.rgb); + + rgb.a = 0; + var start = Color$1.format(rgb, 'rgb'); + + rgb.a = 1; + var end = Color$1.format(rgb, 'rgb'); + + this.setOpacityColorBarBackground(start, end); + } + }, { + key: 'setOpacityColorBarBackground', + value: function setOpacityColorBarBackground(start, end) { + this.refs.$colorbar.css('background', 'linear-gradient(to right, ' + start + ', ' + end + ')'); + } + }, { + key: 'getDefaultValue', + value: function getDefaultValue() { + return this.$store.alpha; + } + }, { + key: 'refreshColorUI', + value: function refreshColorUI(e) { + var dist = this.getCaculatedDist(e); + + this.setColorUI(dist / 100 * this.maxValue); + + this.changeColor({ + a: Math.floor(dist) / 100 * this.maxValue + }); + } + }]); + return Opacity; +}(BaseSlider); + +function isSupported(api, apiParent) { + return api in apiParent; +} + +var enableEyeDropper = isSupported('EyeDropper', window); + +var Eyedropper = function (_UIElement) { + inherits(Eyedropper, _UIElement); + + function Eyedropper() { + classCallCheck(this, Eyedropper); + return possibleConstructorReturn(this, (Eyedropper.__proto__ || Object.getPrototypeOf(Eyedropper)).apply(this, arguments)); + } + + createClass(Eyedropper, [{ + key: 'template', + value: function template() { + return (/*html*/'\n \n ' + ); + } + }, { + key: 'click $button', + value: function click$button() { + var _this2 = this; + + if (enableEyeDropper) { + var eyeDropper = new EyeDropper(); + eyeDropper.open().then(function (result) { + _this2.$store.dispatch('/changeColor', result.sRGBHex); + _this2.$store.emit('lastUpdateColor'); + }); + } + } + }]); + return Eyedropper; +}(UIElement); + +var source = 'macos-control'; + +var ColorControl = function (_UIElement) { + inherits(ColorControl, _UIElement); + + function ColorControl() { + classCallCheck(this, ColorControl); + return possibleConstructorReturn(this, (ColorControl.__proto__ || Object.getPrototypeOf(ColorControl)).apply(this, arguments)); + } + + createClass(ColorControl, [{ + key: 'components', + value: function components() { + return { Value: Value, Opacity: Opacity, Eyedropper: Eyedropper }; + } + }, { + key: 'template', + value: function template() { + + var hasEyeDropper = enableEyeDropper ? 'has-eyedropper' : ''; + var $eyedropper = !!enableEyeDropper ? '\n
        \n
        \n
        \n ' : ''; + + return '\n
        \n
        \n
        \n
        \n
        \n ' + $eyedropper + ' \n
        \n '; + } + }, { + key: 'setBackgroundColor', + value: function setBackgroundColor() { + this.refs.$controlColor.css("background-color", this.$store.dispatch('/toRGB')); + } + }, { + key: 'refresh', + value: function refresh() { + this.setColorUI(); + this.setBackgroundColor(); + } + }, { + key: 'setColorUI', + value: function setColorUI() { + this.Value.setColorUI(); + this.Opacity.setColorUI(); + } + }, { + key: '@changeColor', + value: function changeColor(sourceType) { + if (source != sourceType) { + this.refresh(); + } + } + }, { + key: '@initColor', + value: function initColor() { + this.refresh(); + } + }]); + return ColorControl; +}(UIElement); + +var ColorWheel = function (_UIElement) { + inherits(ColorWheel, _UIElement); + + function ColorWheel(opt) { + classCallCheck(this, ColorWheel); + + var _this = possibleConstructorReturn(this, (ColorWheel.__proto__ || Object.getPrototypeOf(ColorWheel)).call(this, opt)); + + _this.width = 214; + _this.height = 214; + _this.thinkness = 0; + _this.half_thinkness = 0; + _this.source = 'colorwheel'; + return _this; + } + + createClass(ColorWheel, [{ + key: 'template', + value: function template() { + return '\n
        \n \n
        \n
        \n
        \n '; + } + }, { + key: 'refresh', + value: function refresh(isEvent) { + this.setColorUI(isEvent); + } + }, { + key: 'setColorUI', + value: function setColorUI(isEvent) { + this.renderCanvas(); + this.renderValue(); + this.setHueColor(null, isEvent); + } + }, { + key: 'renderValue', + value: function renderValue() { + var value = 1 - this.$store.hsv.v; + this.refs.$valuewheel.css({ + 'background-color': 'rgba(0, 0, 0, ' + value + ')' + }); + } + }, { + key: 'renderWheel', + value: function renderWheel(width, height) { + + if (this.width && !width) width = this.width; + if (this.height && !height) height = this.height; + + var $canvas = new Dom('canvas'); + var context = $canvas.el.getContext('2d'); + $canvas.el.width = width; + $canvas.el.height = height; + $canvas.css({ width: width + 'px', height: height + 'px' }); + + var img = context.getImageData(0, 0, width, height); + var pixels = img.data; + var half_width = Math.floor(width / 2); + var half_height = Math.floor(height / 2); + + var radius = width > height ? half_height : half_width; + var cx = half_width; + var cy = half_height; + + for (var y = 0; y < height; y++) { + for (var x = 0; x < width; x++) { + var rx = x - cx + 1, + ry = y - cy + 1, + d = rx * rx + ry * ry, + hue = caculateAngle(rx, ry); + + var rgb = Color$1.HSVtoRGB(hue, // 0~360 hue + Math.min(Math.sqrt(d) / radius, 1), // 0..1 Saturation + 1 // 0..1 Value + ); + + var index = (y * width + x) * 4; + pixels[index] = rgb.r; + pixels[index + 1] = rgb.g; + pixels[index + 2] = rgb.b; + pixels[index + 3] = 255; + } + } + + context.putImageData(img, 0, 0); + + if (this.thinkness > 0) { + context.globalCompositeOperation = "destination-out"; // destination-out 은 그리는 영역이 지워진다. + context.fillStyle = 'black'; + context.beginPath(); + context.arc(cx, cy, radius - this.thinkness, 0, Math.PI * 2); + context.closePath(); + context.fill(); + } + + return $canvas; + } + }, { + key: 'renderCanvas', + value: function renderCanvas() { + + // only once rendering + if (this.$store.createdWheelCanvas) return; + + var $canvas = this.refs.$colorwheel; + // console.log($canvas); + var context = $canvas.el.getContext('2d'); + + var _$canvas$size = $canvas.size(), + _$canvas$size2 = slicedToArray(_$canvas$size, 2), + width = _$canvas$size2[0], + height = _$canvas$size2[1]; + + if (this.width && !width) width = this.width; + if (this.height && !height) height = this.height; + + $canvas.el.width = width; + $canvas.el.height = height; + $canvas.css({ width: width + 'px', height: height + 'px' }); + + var $wheelCanvas = this.renderWheel(width, height); + + context.drawImage($wheelCanvas.el, 0, 0); + + this.$store.createdWheelCanvas = true; + } + }, { + key: 'getDefaultValue', + value: function getDefaultValue() { + return this.$store.hsv.h; + } + }, { + key: 'getDefaultSaturation', + value: function getDefaultSaturation() { + return this.$store.hsv.s; + } + }, { + key: 'getCurrentXY', + value: function getCurrentXY(e, angle, radius, centerX, centerY) { + return e ? Event.posXY(e) : getXYInCircle(angle, radius, centerX, centerY); + } + }, { + key: 'getRectangle', + value: function getRectangle() { + var width = this.state.get('$el.width'); + var height = this.state.get('$el.height'); + var radius = this.state.get('$colorwheel.width') / 2; + + var minX = this.refs.$el.offset().left; + var centerX = minX + width / 2; + + var minY = this.refs.$el.offset().top; + var centerY = minY + height / 2; + + return { minX: minX, minY: minY, width: width, height: height, radius: radius, centerX: centerX, centerY: centerY }; + } + }, { + key: 'setHueColor', + value: function setHueColor(e, isEvent) { + + if (!this.state.get('$el.width')) return; + + var _getRectangle = this.getRectangle(), + minX = _getRectangle.minX, + minY = _getRectangle.minY, + radius = _getRectangle.radius, + centerX = _getRectangle.centerX, + centerY = _getRectangle.centerY; + + var _getCurrentXY = this.getCurrentXY(e, this.getDefaultValue(), this.getDefaultSaturation() * radius, centerX, centerY), + x = _getCurrentXY.x, + y = _getCurrentXY.y; + + var rx = x - centerX, + ry = y - centerY, + d = rx * rx + ry * ry, + hue = caculateAngle(rx, ry); + + if (d > radius * radius) { + var _getCurrentXY2 = this.getCurrentXY(null, hue, radius, centerX, centerY), + x = _getCurrentXY2.x, + y = _getCurrentXY2.y; + } + + // saturation 을 + var saturation = Math.min(Math.sqrt(d) / radius, 1); + + // set drag pointer position + this.refs.$drag_pointer.css({ + left: x - minX + 'px', + top: y - minY + 'px' + }); + + if (!isEvent) { + this.changeColor({ + type: 'hsv', + h: hue, + s: saturation + }); + } + } + }, { + key: 'changeColor', + value: function changeColor(opt) { + this.$store.dispatch('/changeColor', Object.assign({ + source: this.source + }, opt || {})); + } + }, { + key: '@changeColor', + value: function changeColor(sourceType) { + if (this.source != sourceType) { + this.refresh(true); + } + } + }, { + key: '@initColor', + value: function initColor() { + this.refresh(true); + } + + // Event Bindings + + }, { + key: 'mouseup document', + value: function mouseupDocument(e) { + if (this.isDown) { + this.isDown = false; + this.$store.emit('lastUpdateColor'); + } + } + }, { + key: 'mousemove document', + value: function mousemoveDocument(e) { + if (this.isDown) { + this.setHueColor(e); + } + } + }, { + key: 'mousedown $drag_pointer', + value: function mousedown$drag_pointer(e) { + e.preventDefault(); + this.isDown = true; + } + }, { + key: 'mousedown $el', + value: function mousedown$el(e) { + this.isDown = true; + this.setHueColor(e); + } + }, { + key: 'touchend document', + value: function touchendDocument(e) { + if (this.isDown) { + this.isDown = false; + this.$store.emit('lastUpdateColor'); + } + } + }, { + key: 'touchmove document', + value: function touchmoveDocument(e) { + if (this.isDown) { + this.setHueColor(e); + } + } + }, { + key: 'touchstart $drag_pointer', + value: function touchstart$drag_pointer(e) { + e.preventDefault(); + this.isDown = true; + } + }, { + key: 'touchstart $el', + value: function touchstart$el(e) { + e.preventDefault(); + this.isDown = true; + this.setHueColor(e); + } + }]); + return ColorWheel; +}(UIElement); + +var source$2 = 'chromedevtool-information'; + +var ColorInformation = function (_UIElement) { + inherits(ColorInformation, _UIElement); + + function ColorInformation() { + classCallCheck(this, ColorInformation); + return possibleConstructorReturn(this, (ColorInformation.__proto__ || Object.getPrototypeOf(ColorInformation)).apply(this, arguments)); + } + + createClass(ColorInformation, [{ + key: 'template', + value: function template() { + return (/*html*/'\n
        \n
        \n \n
        \n
        \n
        \n \n
        HEX
        \n
        \n
        \n
        \n
        \n \n
        R
        \n
        \n
        \n \n
        G
        \n
        \n
        \n \n
        B
        \n
        \n
        \n \n
        A
        \n
        \n
        \n
        \n
        \n \n
        H
        \n
        \n
        \n \n
        %
        \n
        S
        \n
        \n
        \n \n
        %
        \n
        L
        \n
        \n
        \n \n
        A
        \n
        \n
        \n
        \n ' + ); + } + }, { + key: 'setCurrentFormat', + value: function setCurrentFormat(format) { + this.format = format; + + this.initFormat(); + } + }, { + key: 'initFormat', + value: function initFormat() { + var _this2 = this; + + var current_format = this.format || 'hex'; + + ['hex', 'rgb', 'hsl'].filter(function (it) { + return it !== current_format; + }).forEach(function (formatString) { + _this2.$el.removeClass(formatString); + }); + + this.$el.addClass(current_format); + } + }, { + key: 'nextFormat', + value: function nextFormat() { + var current_format = this.$store.format || 'hex'; + + var next_format = 'hex'; + if (current_format == 'hex') { + next_format = 'rgb'; + } else if (current_format == 'rgb') { + next_format = 'hsl'; + } else if (current_format == 'hsl') { + next_format = 'hex'; + } + + this.format = next_format; + this.$store.dispatch('/changeFormat', next_format); + this.$store.emit('lastUpdateColor'); + this.initFormat(); + } + }, { + key: 'goToFormat', + value: function goToFormat(to_format) { + this.format = to_format; + this.$store.dispatch('/changeFormat', this.format); + this.$store.emit('lastUpdateColor'); + this.initFormat(); + } + }, { + key: 'getFormat', + value: function getFormat() { + return this.format || 'hex'; + } + }, { + key: 'checkNumberKey', + value: function checkNumberKey(e) { + var code = e.which, + isExcept = false; + + if (code == 37 || code == 39 || code == 8 || code == 46 || code == 9) isExcept = true; + + if (!isExcept && (code < 48 || code > 57)) return false; + + return true; + } + }, { + key: 'checkNotNumberKey', + value: function checkNotNumberKey(e) { + return !this.checkNumberKey(e); + } + }, { + key: 'changeRgbColor', + value: function changeRgbColor() { + this.$store.dispatch('/changeColor', { + type: 'rgb', + r: this.refs.$rgb_r.int(), + g: this.refs.$rgb_g.int(), + b: this.refs.$rgb_b.int(), + a: this.refs.$rgb_a.float(), + source: source$2 + }); + this.$store.emit('lastUpdateColor'); + } + }, { + key: 'changeHslColor', + value: function changeHslColor() { + this.$store.dispatch('/changeColor', { + type: 'hsl', + h: this.refs.$hsl_h.int(), + s: this.refs.$hsl_s.int(), + l: this.refs.$hsl_l.int(), + a: this.refs.$hsl_a.float(), + source: source$2 + }); + this.$store.emit('lastUpdateColor'); + } + }, { + key: '@changeColor', + value: function changeColor(sourceType) { + if (source$2 != sourceType) { + this.refresh(); + } + } + }, { + key: '@initColor', + value: function initColor() { + this.refresh(); + } + }, { + key: 'input $rgb_r', + value: function input$rgb_r(e) { + this.changeRgbColor(); + } + }, { + key: 'input $rgb_g', + value: function input$rgb_g(e) { + this.changeRgbColor(); + } + }, { + key: 'input $rgb_b', + value: function input$rgb_b(e) { + this.changeRgbColor(); + } + }, { + key: 'input $rgb_a', + value: function input$rgb_a(e) { + this.changeRgbColor(); + } + }, { + key: 'input $hsl_h', + value: function input$hsl_h(e) { + this.changeHslColor(); + } + }, { + key: 'input $hsl_s', + value: function input$hsl_s(e) { + this.changeHslColor(); + } + }, { + key: 'input $hsl_l', + value: function input$hsl_l(e) { + this.changeHslColor(); + } + }, { + key: 'input $hsl_a', + value: function input$hsl_a(e) { + this.changeHslColor(); + } + }, { + key: 'keyup $hexCode', + value: function keyup$hexCode(e) { + var code = this.refs.$hexCode.val(); + + if (code.charAt(0) == '#' && (code.length == 7 || code.length === 9)) { + this.$store.dispatch('/changeColor', code, source$2); + this.$store.emit('lastUpdateColor'); + } + } + }, { + key: 'click $formatChangeButton', + value: function click$formatChangeButton(e) { + this.nextFormat(); + } + }, { + key: 'click $el .information-item.hex .input-field .title', + value: function click$elInformationItemHexInputFieldTitle(e) { + this.goToFormat('rgb'); + } + }, { + key: 'click $el .information-item.rgb .input-field .title', + value: function click$elInformationItemRgbInputFieldTitle(e) { + this.goToFormat('hsl'); + } + }, { + key: 'click $el .information-item.hsl .input-field .title', + value: function click$elInformationItemHslInputFieldTitle(e) { + this.goToFormat('hex'); + } + }, { + key: 'setRGBInput', + value: function setRGBInput() { + this.refs.$rgb_r.val(this.$store.rgb.r); + this.refs.$rgb_g.val(this.$store.rgb.g); + this.refs.$rgb_b.val(this.$store.rgb.b); + this.refs.$rgb_a.val(this.$store.alpha); + } + }, { + key: 'setHSLInput', + value: function setHSLInput() { + this.refs.$hsl_h.val(this.$store.hsl.h); + this.refs.$hsl_s.val(this.$store.hsl.s); + this.refs.$hsl_l.val(this.$store.hsl.l); + this.refs.$hsl_a.val(this.$store.alpha); + } + }, { + key: 'setHexInput', + value: function setHexInput() { + this.refs.$hexCode.val(this.$store.dispatch('/toHEX')); + } + }, { + key: 'refresh', + value: function refresh() { + this.setCurrentFormat(this.$store.format); + this.setRGBInput(); + this.setHSLInput(); + this.setHexInput(); + } + }]); + return ColorInformation; +}(UIElement); + +var DATA_COLORSETS_INDEX = 'data-colorsets-index'; + +var ColorSetsChooser = function (_UIElement) { + inherits(ColorSetsChooser, _UIElement); + + function ColorSetsChooser() { + classCallCheck(this, ColorSetsChooser); + return possibleConstructorReturn(this, (ColorSetsChooser.__proto__ || Object.getPrototypeOf(ColorSetsChooser)).apply(this, arguments)); + } + + createClass(ColorSetsChooser, [{ + key: 'template', + value: function template() { + return '\n
        \n
        \n
        \n

        Color Palettes

        \n ×\n
        \n
        \n
        \n
        \n '; + } + }, { + key: 'refresh', + value: function refresh() { + this.load(); + } + }, { + key: '@changeCurrentColorSets', + value: function changeCurrentColorSets() { + this.refresh(); + } + }, { + key: '@toggleColorChooser', + value: function toggleColorChooser() { + this.toggle(); + } + + // loadable + + }, { + key: 'load $colorsetsList', + value: function load$colorsetsList() { + // colorsets + var colorSets = this.$store.dispatch('/getColorSetsList'); + + return '\n
        \n ' + colorSets.map(function (element, index) { + return '\n
        \n

        ' + element.name + '

        \n
        \n
        \n ' + element.colors.filter(function (color, i) { + return i < 5; + }).map(function (color) { + color = color || 'rgba(255, 255, 255, 1)'; + return '
        \n
        \n
        '; + }).join('') + '\n
        \n
        \n
        '; + }).join('') + '\n
        \n '; + } + }, { + key: 'show', + value: function show() { + this.$el.addClass('open'); + } + }, { + key: 'hide', + value: function hide() { + this.$el.removeClass('open'); + } + }, { + key: 'toggle', + value: function toggle() { + this.$el.toggleClass('open'); + } + }, { + key: 'click $toggleButton', + value: function click$toggleButton(e) { + this.toggle(); + } + }, { + key: 'click $colorsetsList .colorsets-item', + value: function click$colorsetsListColorsetsItem(e) { + var $item = e.$delegateTarget; + + if ($item) { + + var index = parseInt($item.attr(DATA_COLORSETS_INDEX)); + + this.$store.dispatch('/setCurrentColorSets', index); + + this.hide(); + } + } + }, { + key: 'destroy', + value: function destroy() { + get(ColorSetsChooser.prototype.__proto__ || Object.getPrototypeOf(ColorSetsChooser.prototype), 'destroy', this).call(this); + + this.hide(); + } + }]); + return ColorSetsChooser; +}(UIElement); + +var CurrentColorSets = function (_UIElement) { + inherits(CurrentColorSets, _UIElement); + + function CurrentColorSets() { + classCallCheck(this, CurrentColorSets); + return possibleConstructorReturn(this, (CurrentColorSets.__proto__ || Object.getPrototypeOf(CurrentColorSets)).apply(this, arguments)); + } + + createClass(CurrentColorSets, [{ + key: 'template', + value: function template() { + return '\n
        \n \n
        \n
        \n '; + } + }, { + key: 'load $colorSetsColorList', + value: function load$colorSetsColorList() { + var currentColorSets = this.$store.dispatch('/getCurrentColorSets'); + var colors = this.$store.dispatch('/getCurrentColors'); + + return '\n
        \n ' + colors.map(function (color, i) { + return '
        \n
        \n
        \n
        '; + }).join('') + ' \n ' + (currentColorSets.edit ? '
        +
        ' : '') + ' \n
        \n '; + } + }, { + key: 'refresh', + value: function refresh() { + this.load(); + } + }, { + key: 'addColor', + value: function addColor(color) { + this.$store.dispatch('/addCurrentColor', color); + } + }, { + key: '@changeCurrentColorSets', + value: function changeCurrentColorSets() { + this.refresh(); + } + }, { + key: 'click $colorSetsChooseButton', + value: function click$colorSetsChooseButton(e) { + this.$store.emit('toggleColorChooser'); + } + }, { + key: 'contextmenu $colorSetsColorList', + value: function contextmenu$colorSetsColorList(e) { + e.preventDefault(); + var currentColorSets = this.$store.dispatch('/getCurrentColorSets'); + + if (!currentColorSets.edit) { + return; + } + + var $target = new Dom(e.target); + + var $item = $target.closest('color-item'); + + if ($item) { + var index = parseInt($item.attr('data-index')); + + this.$store.emit('showContextMenu', e, index); + } else { + this.$store.emit('showContextMenu', e); + } + } + }, { + key: 'click $colorSetsColorList .add-color-item', + value: function click$colorSetsColorListAddColorItem(e) { + this.addColor(this.$store.dispatch('/toColor')); + } + }, { + key: 'click $colorSetsColorList .color-item', + value: function click$colorSetsColorListColorItem(e) { + this.$store.dispatch('/changeColor', e.$delegateTarget.attr('data-color')); + this.$store.emit('lastUpdateColor'); + } + }]); + return CurrentColorSets; +}(UIElement); + +var CurrentColorSetsContextMenu = function (_UIElement) { + inherits(CurrentColorSetsContextMenu, _UIElement); + + function CurrentColorSetsContextMenu() { + classCallCheck(this, CurrentColorSetsContextMenu); + return possibleConstructorReturn(this, (CurrentColorSetsContextMenu.__proto__ || Object.getPrototypeOf(CurrentColorSetsContextMenu)).apply(this, arguments)); + } + + createClass(CurrentColorSetsContextMenu, [{ + key: 'template', + value: function template() { + return '\n
          \n \n \n \n
        \n '; + } + }, { + key: 'show', + value: function show(e, index) { + var $event = Event.pos(e); + + this.$el.css({ + top: $event.clientY - 10 + 'px', + left: $event.clientX + 'px' + }); + this.$el.addClass('show'); + this.selectedColorIndex = index; + + if (typeof this.selectedColorIndex == 'undefined') { + this.$el.addClass('small'); + } else { + this.$el.removeClass('small'); + } + } + }, { + key: 'hide', + value: function hide() { + this.$el.removeClass('show'); + } + }, { + key: 'runCommand', + value: function runCommand(command) { + switch (command) { + case 'remove-color': + this.$store.dispatch('/removeCurrentColor', this.selectedColorIndex); + break; + case 'remove-all-to-the-right': + this.$store.dispatch('/removeCurrentColorToTheRight', this.selectedColorIndex); + break; + case 'clear-palette': + this.$store.dispatch('/clearPalette'); + break; + } + } + }, { + key: '@showContextMenu', + value: function showContextMenu(e, index) { + this.show(e, index); + } + }, { + key: 'click $el .menu-item', + value: function click$elMenuItem(e) { + e.preventDefault(); + + this.runCommand(e.$delegateTarget.attr('data-type')); + this.hide(); + } + }]); + return CurrentColorSetsContextMenu; +}(UIElement); + +var MacOSColorPicker = function (_BaseColorPicker) { + inherits(MacOSColorPicker, _BaseColorPicker); + + function MacOSColorPicker() { + classCallCheck(this, MacOSColorPicker); + return possibleConstructorReturn(this, (MacOSColorPicker.__proto__ || Object.getPrototypeOf(MacOSColorPicker)).apply(this, arguments)); + } + + createClass(MacOSColorPicker, [{ + key: 'template', + value: function template() { + return '\n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n '; + } + }, { + key: 'components', + value: function components() { + return { + colorwheel: ColorWheel, + control: ColorControl, + information: ColorInformation, + currentColorSets: CurrentColorSets, + colorSetsChooser: ColorSetsChooser, + contextMenu: CurrentColorSetsContextMenu + }; + } + }]); + return MacOSColorPicker; +}(BaseColorPicker); + +var Hue = function (_BaseSlider) { + inherits(Hue, _BaseSlider); + + function Hue(opt) { + classCallCheck(this, Hue); + + var _this = possibleConstructorReturn(this, (Hue.__proto__ || Object.getPrototypeOf(Hue)).call(this, opt)); + + _this.minValue = 0; + _this.maxValue = 360; + _this.source = 'hue-control'; + return _this; + } + + createClass(Hue, [{ + key: 'template', + value: function template() { + return '\n
        \n
        \n
        \n
        \n
        \n '; + } + }, { + key: 'getDefaultValue', + value: function getDefaultValue() { + return this.$store.hsv.h; + } + }, { + key: 'refreshColorUI', + value: function refreshColorUI(e) { + + var dist = this.getCaculatedDist(e); + + this.setColorUI(dist / 100 * this.maxValue); + + this.changeColor({ + h: dist / 100 * this.maxValue, + type: 'hsv' + }); + } + }]); + return Hue; +}(BaseSlider); + +var source$3 = 'chromedevtool-control'; + +var ColorControl$2 = function (_UIElement) { + inherits(ColorControl, _UIElement); + + function ColorControl() { + classCallCheck(this, ColorControl); + return possibleConstructorReturn(this, (ColorControl.__proto__ || Object.getPrototypeOf(ColorControl)).apply(this, arguments)); + } + + createClass(ColorControl, [{ + key: 'components', + value: function components() { + return { Hue: Hue, Opacity: Opacity, Eyedropper: Eyedropper }; + } + }, { + key: 'template', + value: function template() { + + var hasEyeDropper = enableEyeDropper ? 'has-eyedropper' : ''; + var $eyedropper = !!enableEyeDropper ? '\n
        \n
        \n
        \n ' : ''; + + return '\n
        \n
        \n
        \n
        \n
        \n ' + $eyedropper + ' \n
        \n '; + } + }, { + key: 'setBackgroundColor', + value: function setBackgroundColor() { + this.refs.$controlColor.css("background-color", this.$store.dispatch('/toRGB')); + } + }, { + key: 'refresh', + value: function refresh() { + this.setColorUI(); + this.setBackgroundColor(); + } + }, { + key: 'setColorUI', + value: function setColorUI() { + this.Hue.setColorUI(); + this.Opacity.setColorUI(); + } + }, { + key: '@changeColor', + value: function changeColor(sourceType) { + if (source$3 != sourceType) { + this.refresh(); + } + } + }, { + key: '@initColor', + value: function initColor() { + this.refresh(); + } + }]); + return ColorControl; +}(UIElement); + +var source$4 = 'chromedevtool-palette'; + +var ColorPalette = function (_UIElement) { + inherits(ColorPalette, _UIElement); + + function ColorPalette() { + classCallCheck(this, ColorPalette); + return possibleConstructorReturn(this, (ColorPalette.__proto__ || Object.getPrototypeOf(ColorPalette)).apply(this, arguments)); + } + + createClass(ColorPalette, [{ + key: 'template', + value: function template() { + return '\n
        \n
        \n
        \n
        \n
        \n
        \n
        \n '; + } + }, { + key: 'setBackgroundColor', + value: function setBackgroundColor(color) { + this.$el.css("background-color", color); + } + }, { + key: 'refresh', + value: function refresh() { + this.setColorUI(); + } + }, { + key: 'caculateSV', + value: function caculateSV() { + var pos = this.drag_pointer_pos || { x: 0, y: 0 }; + + var width = this.state.get('$el.width'); + var height = this.state.get('$el.height'); + + var s = pos.x / width; + var v = (height - pos.y) / height; + + this.$store.dispatch('/changeColor', { + type: 'hsv', + s: s, + v: v, + source: source$4 + }); + } + }, { + key: 'setColorUI', + value: function setColorUI() { + var x = this.state.get('$el.width') * this.$store.hsv.s, + y = this.state.get('$el.height') * (1 - this.$store.hsv.v); + + this.refs.$drag_pointer.css({ + left: x + "px", + top: y + "px" + }); + + this.drag_pointer_pos = { x: x, y: y }; + + this.setBackgroundColor(this.$store.dispatch('/getHueColor')); + } + }, { + key: 'setMainColor', + value: function setMainColor(e) { + // e.preventDefault(); + var pos = this.$el.offset(); // position for screen + var w = this.state.get('$el.contentWidth'); + var h = this.state.get('$el.contentHeight'); + + var x = Event.pos(e).pageX - pos.left; + var y = Event.pos(e).pageY - pos.top; + + if (x < 0) x = 0;else if (x > w) x = w; + + if (y < 0) y = 0;else if (y > h) y = h; + + this.refs.$drag_pointer.css({ + left: x + 'px', + top: y + 'px' + }); + + this.drag_pointer_pos = { x: x, y: y }; + + this.caculateSV(); + } + }, { + key: '@changeColor', + value: function changeColor(sourceType) { + if (source$4 != sourceType) { + this.refresh(); + } + } + }, { + key: '@initColor', + value: function initColor() { + this.refresh(); + } + }, { + key: 'mouseup document', + value: function mouseupDocument(e) { + if (this.isDown) { + this.isDown = false; + this.$store.emit('lastUpdateColor'); + } + } + }, { + key: 'mousemove document', + value: function mousemoveDocument(e) { + if (this.isDown) { + this.setMainColor(e); + } + } + }, { + key: 'mousedown', + value: function mousedown(e) { + this.isDown = true; + this.setMainColor(e); + } + }, { + key: 'touchend document', + value: function touchendDocument(e) { + if (this.isDown) { + this.isDown = false; + this.$store.emit('lastUpdateColor'); + } + } + }, { + key: 'touchmove document', + value: function touchmoveDocument(e) { + if (this.isDown) { + this.setMainColor(e); + } + } + }, { + key: 'touchstart', + value: function touchstart(e) { + e.preventDefault(); + this.isDown = true; + this.setMainColor(e); + } + }]); + return ColorPalette; +}(UIElement); + +var ChromeDevToolColorPicker = function (_BaseColorPicker) { + inherits(ChromeDevToolColorPicker, _BaseColorPicker); + + function ChromeDevToolColorPicker() { + classCallCheck(this, ChromeDevToolColorPicker); + return possibleConstructorReturn(this, (ChromeDevToolColorPicker.__proto__ || Object.getPrototypeOf(ChromeDevToolColorPicker)).apply(this, arguments)); + } + + createClass(ChromeDevToolColorPicker, [{ + key: 'template', + value: function template() { + return '\n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n '; + } + }, { + key: 'components', + value: function components() { + return { + palette: ColorPalette, + control: ColorControl$2, + information: ColorInformation, + currentColorSets: CurrentColorSets, + colorSetsChooser: ColorSetsChooser, + contextMenu: CurrentColorSetsContextMenu + }; + } + }]); + return ChromeDevToolColorPicker; +}(BaseColorPicker); + +var source$5 = 'mini-control'; + +var ColorControl$4 = function (_UIElement) { + inherits(ColorControl, _UIElement); + + function ColorControl() { + classCallCheck(this, ColorControl); + return possibleConstructorReturn(this, (ColorControl.__proto__ || Object.getPrototypeOf(ColorControl)).apply(this, arguments)); + } + + createClass(ColorControl, [{ + key: 'components', + value: function components() { + return { Hue: Hue, Opacity: Opacity }; + } + }, { + key: 'template', + value: function template() { + return '\n
        \n
        \n
        \n
        \n '; + } + }, { + key: 'refresh', + value: function refresh() { + this.setColorUI(); + } + }, { + key: 'setColorUI', + value: function setColorUI() { + this.Hue.setColorUI(); + this.Opacity.setColorUI(); + } + }, { + key: '@changeColor', + value: function changeColor(sourceType) { + if (source$5 != sourceType) { + this.refresh(); + } + } + }, { + key: '@initColor', + value: function initColor() { + this.refresh(); + } + }]); + return ColorControl; +}(UIElement); + +var MiniColorPicker = function (_BaseColorPicker) { + inherits(MiniColorPicker, _BaseColorPicker); + + function MiniColorPicker() { + classCallCheck(this, MiniColorPicker); + return possibleConstructorReturn(this, (MiniColorPicker.__proto__ || Object.getPrototypeOf(MiniColorPicker)).apply(this, arguments)); + } + + createClass(MiniColorPicker, [{ + key: 'template', + value: function template() { + return '\n
        \n
        \n
        \n
        \n '; + } + }, { + key: 'components', + value: function components() { + return { + palette: ColorPalette, + control: ColorControl$4 + }; + } + }]); + return MiniColorPicker; +}(BaseColorPicker); + +var VerticalSlider = function (_BaseSlider) { + inherits(VerticalSlider, _BaseSlider); + + function VerticalSlider(opt) { + classCallCheck(this, VerticalSlider); + + var _this = possibleConstructorReturn(this, (VerticalSlider.__proto__ || Object.getPrototypeOf(VerticalSlider)).call(this, opt)); + + _this.source = 'vertical-slider'; + return _this; + } + + /** get max height for vertical slider */ + + + createClass(VerticalSlider, [{ + key: 'getMaxDist', + value: function getMaxDist() { + return this.state.get('$container.height'); + } + + /** set mouse pointer for vertical slider */ + + }, { + key: 'setMousePosition', + value: function setMousePosition(y) { + this.refs.$bar.css({ top: y + 'px' }); + } + + /** get mouse position by pageY for vertical slider */ + + }, { + key: 'getMousePosition', + value: function getMousePosition(e) { + return Event.pos(e).pageY; + } + + /** get min position for vertial slider */ + + }, { + key: 'getMinPosition', + value: function getMinPosition() { + return this.refs.$container.offset().top; + } + + /** get caculated dist for domain value */ + + }, { + key: 'getCaculatedDist', + value: function getCaculatedDist(e) { + var current = e ? this.getMousePosition(e) : this.getCurrent(this.getDefaultValue() / this.maxValue); + var dist = 100 - this.getDist(current); + + return dist; + } + + /** set drag bar position */ + + }, { + key: 'setColorUI', + value: function setColorUI(v) { + + v = v || this.getDefaultValue(); + + if (v <= this.minValue) { + this.refs.$bar.addClass('first').removeClass('last'); + } else if (v >= this.maxValue) { + this.refs.$bar.addClass('last').removeClass('first'); + } else { + this.refs.$bar.removeClass('last').removeClass('first'); + } + + var per = 1 - (v || 0) / this.maxValue; + + this.setMousePosition(this.getMaxDist() * per); + } + }]); + return VerticalSlider; +}(BaseSlider); + +var VerticalHue = function (_VerticalSlider) { + inherits(VerticalHue, _VerticalSlider); + + function VerticalHue(opt) { + classCallCheck(this, VerticalHue); + + var _this = possibleConstructorReturn(this, (VerticalHue.__proto__ || Object.getPrototypeOf(VerticalHue)).call(this, opt)); + + _this.minValue = 0; + _this.maxValue = 360; + _this.source = 'vertical-hue-control'; + return _this; + } + + createClass(VerticalHue, [{ + key: 'template', + value: function template() { + return '\n
        \n
        \n
        \n
        \n
        \n '; + } + }, { + key: 'getDefaultValue', + value: function getDefaultValue() { + return this.$store.hsv.h; + } + }, { + key: 'refreshColorUI', + value: function refreshColorUI(e) { + + var dist = this.getCaculatedDist(e); + + this.setColorUI(dist / 100 * this.maxValue); + + this.changeColor({ + h: dist / 100 * this.maxValue, + type: 'hsv' + }); + } + }]); + return VerticalHue; +}(VerticalSlider); + +var Opacity$2 = function (_VerticalSlider) { + inherits(Opacity, _VerticalSlider); + + function Opacity(opt) { + classCallCheck(this, Opacity); + + var _this = possibleConstructorReturn(this, (Opacity.__proto__ || Object.getPrototypeOf(Opacity)).call(this, opt)); + + _this.source = 'vertical-opacity-control'; + return _this; + } + + createClass(Opacity, [{ + key: 'template', + value: function template() { + return '\n
        \n
        \n
        \n
        \n
        \n
        \n '; + } + }, { + key: 'refresh', + value: function refresh() { + get(Opacity.prototype.__proto__ || Object.getPrototypeOf(Opacity.prototype), 'refresh', this).call(this); + this.setOpacityColorBar(); + } + }, { + key: 'setOpacityColorBar', + value: function setOpacityColorBar() { + var rgb = Object.assign({}, this.$store.rgb); + + rgb.a = 0; + var start = Color$1.format(rgb, 'rgb'); + + rgb.a = 1; + var end = Color$1.format(rgb, 'rgb'); + + this.refs.$colorbar.css('background', 'linear-gradient(to top, ' + start + ', ' + end + ')'); + } + }, { + key: 'getDefaultValue', + value: function getDefaultValue() { + return this.$store.alpha; + } + }, { + key: 'refreshColorUI', + value: function refreshColorUI(e) { + var dist = this.getCaculatedDist(e); + + this.setColorUI(dist / 100 * this.maxValue); + + this.changeColor({ + a: Math.floor(dist) / 100 * this.maxValue + }); + } + }]); + return Opacity; +}(VerticalSlider); + +var source$6 = 'mini-control'; + +var ColorControl$6 = function (_UIElement) { + inherits(ColorControl, _UIElement); + + function ColorControl() { + classCallCheck(this, ColorControl); + return possibleConstructorReturn(this, (ColorControl.__proto__ || Object.getPrototypeOf(ColorControl)).apply(this, arguments)); + } + + createClass(ColorControl, [{ + key: 'components', + value: function components() { + return { Hue: VerticalHue, Opacity: Opacity$2 }; + } + }, { + key: 'template', + value: function template() { + return '
        '; + } + }, { + key: 'refresh', + value: function refresh() { + this.setColorUI(); + } + }, { + key: 'setColorUI', + value: function setColorUI() { + this.Hue.setColorUI(); + this.Opacity.setColorUI(); + } + }, { + key: '@changeColor', + value: function changeColor(sourceType) { + if (source$6 != sourceType) { + this.refresh(); + } + } + }, { + key: '@initColor', + value: function initColor() { + this.refresh(); + } + }]); + return ColorControl; +}(UIElement); + +var MiniColorPicker$2 = function (_BaseColorPicker) { + inherits(MiniColorPicker, _BaseColorPicker); + + function MiniColorPicker() { + classCallCheck(this, MiniColorPicker); + return possibleConstructorReturn(this, (MiniColorPicker.__proto__ || Object.getPrototypeOf(MiniColorPicker)).apply(this, arguments)); + } + + createClass(MiniColorPicker, [{ + key: 'template', + value: function template() { + return '\n
        \n
        \n
        \n '; + } + }, { + key: 'components', + value: function components() { + return { + palette: ColorPalette, + control: ColorControl$6 + }; + } + }]); + return MiniColorPicker; +}(BaseColorPicker); + +var source$7 = 'macos-control'; + +var ColorControl$8 = function (_UIElement) { + inherits(ColorControl, _UIElement); + + function ColorControl() { + classCallCheck(this, ColorControl); + return possibleConstructorReturn(this, (ColorControl.__proto__ || Object.getPrototypeOf(ColorControl)).apply(this, arguments)); + } + + createClass(ColorControl, [{ + key: 'components', + value: function components() { + return { Value: Value, Opacity: Opacity, Eyedropper: Eyedropper }; + } + }, { + key: 'template', + value: function template() { + + var hasEyeDropper = enableEyeDropper ? 'has-eyedropper' : ''; + var $eyedropper = !!enableEyeDropper ? '\n
        \n
        \n
        \n ' : ''; + + return '\n
        \n
        \n
        \n
        \n
        \n ' + $eyedropper + ' \n
        \n '; + } + }, { + key: 'setBackgroundColor', + value: function setBackgroundColor() { + this.refs.$controlColor.css("background-color", this.$store.dispatch('/toRGB')); + } + }, { + key: 'refresh', + value: function refresh() { + this.setColorUI(); + this.setBackgroundColor(); + } + }, { + key: 'setColorUI', + value: function setColorUI() { + this.Value.setColorUI(); + this.Opacity.setColorUI(); + } + }, { + key: '@changeColor', + value: function changeColor(sourceType) { + if (source$7 != sourceType) { + this.refresh(); + } + } + }, { + key: '@initColor', + value: function initColor() { + this.refresh(); + } + }]); + return ColorControl; +}(UIElement); + +var ColorRing = function (_ColorWheel) { + inherits(ColorRing, _ColorWheel); + + function ColorRing(opt) { + classCallCheck(this, ColorRing); + + var _this = possibleConstructorReturn(this, (ColorRing.__proto__ || Object.getPrototypeOf(ColorRing)).call(this, opt)); + + _this.width = 214; + _this.height = 214; + _this.thinkness = 16; + _this.half_thinkness = _this.thinkness / 2; + _this.source = 'colorring'; + return _this; + } + + createClass(ColorRing, [{ + key: 'template', + value: function template() { + return '\n
        \n \n
        \n
        \n '; + } + }, { + key: 'setColorUI', + value: function setColorUI(isEvent) { + this.renderCanvas(); + this.setHueColor(null, isEvent); + } + }, { + key: 'getDefaultValue', + value: function getDefaultValue() { + return this.$store.hsv.h; + } + }, { + key: 'setHueColor', + value: function setHueColor(e, isEvent) { + + if (!this.state.get('$el.width')) return; + + var _getRectangle = this.getRectangle(), + minX = _getRectangle.minX, + minY = _getRectangle.minY, + radius = _getRectangle.radius, + centerX = _getRectangle.centerX, + centerY = _getRectangle.centerY; + + var _getCurrentXY = this.getCurrentXY(e, this.getDefaultValue(), radius, centerX, centerY), + x = _getCurrentXY.x, + y = _getCurrentXY.y; + + var rx = x - centerX, + ry = y - centerY, + hue = caculateAngle(rx, ry); + + { + var _getCurrentXY2 = this.getCurrentXY(null, hue, radius - this.half_thinkness, centerX, centerY), + x = _getCurrentXY2.x, + y = _getCurrentXY2.y; + } + + // set drag pointer position + this.refs.$drag_pointer.css({ + left: x - minX + 'px', + top: y - minY + 'px' + }); + + if (!isEvent) { + this.changeColor({ + type: 'hsv', + h: hue + }); + } + } + }]); + return ColorRing; +}(ColorWheel); + +// import ColorWheel from '../ui/ColorWheel' +var RingColorPicker = function (_BaseColorPicker) { + inherits(RingColorPicker, _BaseColorPicker); + + function RingColorPicker() { + classCallCheck(this, RingColorPicker); + return possibleConstructorReturn(this, (RingColorPicker.__proto__ || Object.getPrototypeOf(RingColorPicker)).apply(this, arguments)); + } + + createClass(RingColorPicker, [{ + key: 'template', + value: function template() { + return '\n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n '; + } + }, { + key: 'components', + value: function components() { + return { + colorring: ColorRing, + palette: ColorPalette, + control: ColorControl$8, + information: ColorInformation, + currentColorSets: CurrentColorSets, + colorSetsChooser: ColorSetsChooser, + contextMenu: CurrentColorSetsContextMenu + }; + } + }]); + return RingColorPicker; +}(BaseColorPicker); + +var ColorControl$10 = function (_UIElement) { + inherits(ColorControl, _UIElement); + + function ColorControl() { + classCallCheck(this, ColorControl); + return possibleConstructorReturn(this, (ColorControl.__proto__ || Object.getPrototypeOf(ColorControl)).apply(this, arguments)); + } + + createClass(ColorControl, [{ + key: 'components', + value: function components() { + return { Hue: VerticalHue, Opacity: Opacity$2 }; + } + }, { + key: 'template', + value: function template() { + return '\n
        \n
        \n
        \n
        \n '; + } + }, { + key: 'refresh', + value: function refresh() { + this.setColorUI(); + } + }, { + key: 'setColorUI', + value: function setColorUI() { + this.Hue.setColorUI(); + this.Opacity.setColorUI(); + } + }, { + key: '@changeColor', + value: function changeColor() { + this.refresh(); + } + }, { + key: '@initColor', + value: function initColor() { + this.refresh(); + } + }]); + return ColorControl; +}(UIElement); + +var XDColorPicker = function (_BaseColorPicker) { + inherits(XDColorPicker, _BaseColorPicker); + + function XDColorPicker() { + classCallCheck(this, XDColorPicker); + return possibleConstructorReturn(this, (XDColorPicker.__proto__ || Object.getPrototypeOf(XDColorPicker)).apply(this, arguments)); + } + + createClass(XDColorPicker, [{ + key: 'template', + value: function template() { + return '\n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n '; + } + }, { + key: 'components', + value: function components() { + return { + palette: ColorPalette, + control: ColorControl$10, + information: ColorInformation, + currentColorSets: CurrentColorSets, + colorSetsChooser: ColorSetsChooser, + contextMenu: CurrentColorSetsContextMenu + }; + } + }]); + return XDColorPicker; +}(BaseColorPicker); + +var source$8 = 'mini-control'; + +var ColorControl$12 = function (_UIElement) { + inherits(ColorControl, _UIElement); + + function ColorControl() { + classCallCheck(this, ColorControl); + return possibleConstructorReturn(this, (ColorControl.__proto__ || Object.getPrototypeOf(ColorControl)).apply(this, arguments)); + } + + createClass(ColorControl, [{ + key: 'components', + value: function components() { + return { Hue: VerticalHue, Opacity: Opacity$2 }; + } + }, { + key: 'template', + value: function template() { + return (/*html*/'\n
        \n
        \n
        \n
        \n ' + ); + } + }, { + key: 'refresh', + value: function refresh() { + this.setColorUI(); + } + }, { + key: 'setColorUI', + value: function setColorUI() { + this.Hue.setColorUI(); + this.Opacity.setColorUI(); + } + }, { + key: '@changeColor', + value: function changeColor(sourceType) { + if (source$8 != sourceType) { + this.refresh(); + } + } + }, { + key: '@initColor', + value: function initColor() { + this.refresh(); + } + }]); + return ColorControl; +}(UIElement); + +var VSCodePicker = function (_BaseColorPicker) { + inherits(VSCodePicker, _BaseColorPicker); + + function VSCodePicker() { + classCallCheck(this, VSCodePicker); + return possibleConstructorReturn(this, (VSCodePicker.__proto__ || Object.getPrototypeOf(VSCodePicker)).apply(this, arguments)); + } + + createClass(VSCodePicker, [{ + key: 'template', + value: function template() { + + var hasEyeDropper = enableEyeDropper ? 'has-eyedropper' : ''; + var $eyedropper = !!enableEyeDropper ? '\n
        \n
        \n
        \n ' : ''; + + return (/*html*/'\n
        \n
        \n
        \n
        \n
        \n ' + $eyedropper + ' \n
        \n
        \n
        \n
        \n
        \n
        \n ' + ); + } + }, { + key: 'components', + value: function components() { + return { + palette: ColorPalette, + control: ColorControl$12, + Eyedropper: Eyedropper + }; + } + }, { + key: 'initColorWithoutChangeEvent', + value: function initColorWithoutChangeEvent(color) { + this.$store.dispatch('/initColor', color); + this.refresh(); + } + }, { + key: 'setBackgroundColor', + value: function setBackgroundColor() { + var color = this.$store.dispatch('/toColor'); + var rgb = this.$store.rgb; + var bValue = Color$1.brightness(rgb.r, rgb.g, rgb.b); + + this.refs.$colorview.css({ + "background-color": color, + 'color': bValue > 127 ? 'black' : 'white' + }); + this.refs.$colorview.html(color); + } + }, { + key: 'click $colorview', + value: function click$colorview(e) { + this.nextFormat(); + } + }, { + key: 'nextFormat', + value: function nextFormat() { + var current_format = this.$store.format || 'hex'; + + var next_format = 'hex'; + if (current_format == 'hex') { + next_format = 'rgb'; + } else if (current_format == 'rgb') { + next_format = 'hsl'; + } else if (current_format == 'hsl') { + next_format = 'hex'; + } + + this.$store.dispatch('/changeFormat', next_format); + this.$store.emit('lastUpdateColor'); + this.refresh(); + } + }, { + key: 'refresh', + value: function refresh() { + this.setBackgroundColor(); + } + }, { + key: '@changeColor', + value: function changeColor() { + this.refresh(); + } + }, { + key: '@initColor', + value: function initColor() { + this.refresh(); + } + }]); + return VSCodePicker; +}(BaseColorPicker); + +var source$9 = 'chromedevtool-control'; + +var ColorControl$14 = function (_UIElement) { + inherits(ColorControl, _UIElement); + + function ColorControl() { + classCallCheck(this, ColorControl); + return possibleConstructorReturn(this, (ColorControl.__proto__ || Object.getPrototypeOf(ColorControl)).apply(this, arguments)); + } + + createClass(ColorControl, [{ + key: 'components', + value: function components() { + return { Hue: Hue, Opacity: Opacity, Eyedropper: Eyedropper }; + } + }, { + key: 'template', + value: function template() { + + var hasEyeDropper = enableEyeDropper ? 'has-eyedropper' : ''; + var $eyedropper = !!enableEyeDropper ? '\n
        \n
        \n
        \n ' : ''; + + return '\n
        \n
        \n ' + $eyedropper + ' \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n\n
        \n '; + } + }, { + key: 'setBackgroundColor', + value: function setBackgroundColor() { + this.refs.$controlColor.css("background-color", this.$store.dispatch('/toRGB')); + } + }, { + key: 'refresh', + value: function refresh() { + this.setColorUI(); + this.setBackgroundColor(); + } + }, { + key: 'setColorUI', + value: function setColorUI() { + this.Hue.setColorUI(); + this.Opacity.setColorUI(); + } + }, { + key: '@changeColor', + value: function changeColor(sourceType) { + if (source$9 != sourceType) { + this.refresh(); + } + } + }, { + key: '@initColor', + value: function initColor() { + this.refresh(); + } + }]); + return ColorControl; +}(UIElement); + +var BoxColorPicker = function (_BaseColorPicker) { + inherits(BoxColorPicker, _BaseColorPicker); + + function BoxColorPicker() { + classCallCheck(this, BoxColorPicker); + return possibleConstructorReturn(this, (BoxColorPicker.__proto__ || Object.getPrototypeOf(BoxColorPicker)).apply(this, arguments)); + } + + createClass(BoxColorPicker, [{ + key: 'template', + value: function template() { + return (/*html*/'\n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n
        \n ' + ); + } + }, { + key: 'components', + value: function components() { + return { + palette: ColorPalette, + control: ColorControl$14, + information: ColorInformation, + currentColorSets: CurrentColorSets, + colorSetsChooser: ColorSetsChooser, + contextMenu: CurrentColorSetsContextMenu + }; + } + }]); + return BoxColorPicker; +}(BaseColorPicker); + +var ColorPicker = { + create: function create(opts) { + switch (opts.type) { + case 'box': + return new BoxColorPicker(opts); + case 'macos': + return new MacOSColorPicker(opts); + case 'xd': + return new XDColorPicker(opts); + case 'ring': + return new RingColorPicker(opts); + case 'mini': + return new MiniColorPicker(opts); + case 'vscode': + return new VSCodePicker(opts); + case 'mini-vertical': + return new MiniColorPicker$2(opts); + case 'sketch': + case 'palette': + default: + return new ChromeDevToolColorPicker(opts); + } + }, + + ColorPicker: ChromeDevToolColorPicker, + ChromeDevToolColorPicker: ChromeDevToolColorPicker, + MacOSColorPicker: MacOSColorPicker, + RingColorPicker: RingColorPicker, + MiniColorPicker: MiniColorPicker, + VSCodePicker: VSCodePicker, + MiniVerticalColorPicker: MiniColorPicker$2 +}; + +var colorpicker_class = 'codemirror-colorview'; +var colorpicker_background_class = 'codemirror-colorview-background'; +// Excluded tokens do not show color views.. +var excluded_token = ['comment', 'builtin', 'qualifier', 'tag', 'property', 'property error', 'variable', 'variable-2']; + +function onChange(cm, evt) { + if (evt.origin == 'setValue') { + // if content is changed by setValue method, it initialize markers + // cm.state.colorpicker.close_color_picker(); + cm.state.colorpicker.init_color_update(); + cm.state.colorpicker.style_color_update(); + } else { + cm.state.colorpicker.style_color_update(cm.getCursor().line); + } +} + +function onUpdate(cm, evt) { + if (!cm.state.colorpicker.isUpdate) { + cm.state.colorpicker.isUpdate = true; + cm.state.colorpicker.close_color_picker(); + cm.state.colorpicker.init_color_update(); + cm.state.colorpicker.style_color_update(); + } +} + +function onRefresh(cm, evt) { + onChange(cm, { origin: 'setValue' }); +} + +function onKeyup(cm, evt) { + cm.state.colorpicker.keyup(evt); +} + +function onMousedown(cm, evt) { + if (cm.state.colorpicker.is_edit_mode()) { + cm.state.colorpicker.check_mousedown(evt); + } +} + +function onPaste(cm, evt) { + onChange(cm, { origin: 'setValue' }); +} + +function onScroll(cm) { + cm.state.colorpicker.close_color_picker(); +} + +function onBlur(cm) { + cm.state.colorpicker.hide_delay_color_picker(cm.state.colorpicker.opt.hideDelay || 1000); +} + +function debounce(callback, delay) { + + var t = undefined; + + return function (cm, e) { + if (t) { + clearTimeout(t); + } + + t = setTimeout(function () { + callback(cm, e); + }, delay || 300); + }; +} + +function has_class(el, cls) { + if (!el || !el.className) { + return false; + } else { + var newClass = ' ' + el.className + ' '; + return newClass.indexOf(' ' + cls + ' ') > -1; + } +} + +var ColorView = function () { + function ColorView(cm, opt) { + classCallCheck(this, ColorView); + + if (typeof opt == 'boolean') { + opt = { mode: 'edit' }; + } else { + opt = Object.assign({ mode: 'edit' }, opt || {}); + } + + this.opt = opt; + this.cm = cm; + this.markers = {}; + + // set excluded token + this.excluded_token = this.opt.excluded_token || excluded_token; + + if (this.opt.colorpicker) { + this.colorpicker = this.opt.colorpicker(this.opt); + } else { + this.colorpicker = ColorPicker.create(this.opt); + } + + this.init_event(); + } + + createClass(ColorView, [{ + key: 'init_event', + value: function init_event() { + + this.cm.on('mousedown', onMousedown); + this.cm.on('keyup', onKeyup); + this.cm.on('change', onChange); + this.cm.on('update', onUpdate); + this.cm.on('refresh', onRefresh); + this.cm.on('blur', onBlur); + + // create paste callback + this.onPasteCallback = function (cm, callback) { + return function (evt) { + callback.call(this, cm, evt); + }; + }(this.cm, onPaste); + + this.onScrollEvent = debounce(onScroll, 50); + + this.cm.getWrapperElement().addEventListener('paste', this.onPasteCallback); + + if (this.is_edit_mode()) { + this.cm.on('scroll', this.onScrollEvent); + } + } + }, { + key: 'is_edit_mode', + value: function is_edit_mode() { + return this.opt.mode == 'edit'; + } + }, { + key: 'is_view_mode', + value: function is_view_mode() { + return this.opt.mode == 'view'; + } + }, { + key: 'destroy', + value: function destroy() { + this.cm.off('mousedown', onMousedown); + this.cm.off('keyup', onKeyup); + this.cm.off('change', onChange); + this.cm.off('blur', onBlur); + + this.cm.getWrapperElement().removeEventListener('paste', this.onPasteCallback); + + if (this.is_edit_mode()) { + this.cm.off('scroll', this.onScrollEvent); + } + } + }, { + key: 'hasClass', + value: function hasClass(el, className) { + if (!el.className) { + return false; + } else { + var newClass = ' ' + el.className + ' '; + return newClass.indexOf(' ' + className + ' ') > -1; + } + } + }, { + key: 'check_mousedown', + value: function check_mousedown(evt) { + if (this.hasClass(evt.target, colorpicker_background_class)) { + this.open_color_picker(evt.target.parentNode); + } else { + this.close_color_picker(); + } + } + }, { + key: 'popup_color_picker', + value: function popup_color_picker(defalutColor) { + var cursor = this.cm.getCursor(); + var self = this; + var colorMarker = { + lineNo: cursor.line, + ch: cursor.ch, + color: defalutColor || '#FFFFFF', + isShortCut: true + }; + + Object.keys(this.markers).forEach(function (key) { + var searchKey = "#" + key; + if (searchKey.indexOf("#" + colorMarker.lineNo + ":") > -1) { + var marker = self.markers[key]; + + if (marker.ch <= colorMarker.ch && colorMarker.ch <= marker.ch + marker.color.length) { + // when cursor has marker + colorMarker.ch = marker.ch; + colorMarker.color = marker.color; + colorMarker.nameColor = marker.nameColor; + } + } + }); + + this.open_color_picker(colorMarker); + } + }, { + key: 'open_color_picker', + value: function open_color_picker(el) { + var _this = this; + + var lineNo = el.lineNo; + var ch = el.ch; + var nameColor = el.nameColor; + var color = el.color; + + if (this.colorpicker) { + var prevColor = color; + var pos = this.cm.charCoords({ line: lineNo, ch: ch }); + this.colorpicker.show({ + left: pos.left, + top: pos.bottom, + isShortCut: el.isShortCut || false, + hideDelay: this.opt.hideDelay || 2000 + }, nameColor || color, function (newColor) { + _this.cm.replaceRange(newColor, { line: lineNo, ch: ch }, { line: lineNo, ch: ch + prevColor.length }, '*colorpicker'); + _this.cm.focus(); + prevColor = newColor; + }); + } + } + }, { + key: 'close_color_picker', + value: function close_color_picker() { + if (this.colorpicker) { + this.colorpicker.hide(); + } + } + }, { + key: 'hide_delay_color_picker', + value: function hide_delay_color_picker() { + if (this.colorpicker) { + this.colorpicker.runHideDelay(); + } + } + }, { + key: 'key', + value: function key(lineNo, ch) { + return [lineNo, ch].join(":"); + } + }, { + key: 'keyup', + value: function keyup(evt) { + + if (this.colorpicker) { + if (evt.key == 'Escape') { + this.colorpicker.hide(); + } else if (this.colorpicker.isShortCut == false) { + this.colorpicker.hide(); + } + } + } + }, { + key: 'init_color_update', + value: function init_color_update() { + this.markers = {}; // initialize marker list + } + }, { + key: 'style_color_update', + value: function style_color_update(lineHandle) { + if (lineHandle) { + this.match(lineHandle); + } else { + var max = this.cm.lineCount(); + + for (var lineNo = 0; lineNo < max; lineNo++) { + this.match(lineNo); + } + } + } + }, { + key: 'empty_marker', + value: function empty_marker(lineNo, lineHandle) { + var list = lineHandle.markedSpans || []; + + for (var i = 0, len = list.length; i < len; i++) { + var key = this.key(lineNo, list[i].from); + + if (key && has_class(list[i].marker.replacedWith, colorpicker_class)) { + delete this.markers[key]; + list[i].marker.clear(); + } + } + } + }, { + key: 'match_result', + value: function match_result(lineHandle) { + return Color$1.matches(lineHandle.text); + } + }, { + key: 'submatch', + value: function submatch(lineNo, lineHandle) { + var _this2 = this; + + this.empty_marker(lineNo, lineHandle); + + var result = this.match_result(lineHandle); + var obj = { next: 0 }; + + result.forEach(function (item) { + _this2.render(obj, lineNo, lineHandle, item.color, item.nameColor); + }); + } + }, { + key: 'match', + value: function match(lineNo) { + var lineHandle = this.cm.getLineHandle(lineNo); + var self = this; + this.cm.operation(function () { + self.submatch(lineNo, lineHandle); + }); + } + }, { + key: 'make_element', + value: function make_element() { + var el = document.createElement('div'); + + el.className = colorpicker_class; + + if (this.is_edit_mode()) { + el.title = "open color picker"; + } else { + el.title = ""; + } + + el.back_element = this.make_background_element(); + el.appendChild(el.back_element); + + return el; + } + }, { + key: 'make_background_element', + value: function make_background_element() { + var el = document.createElement('div'); + + el.className = colorpicker_background_class; + + return el; + } + }, { + key: 'set_state', + value: function set_state(lineNo, start, color, nameColor) { + var marker = this.create_marker(lineNo, start); + + marker.lineNo = lineNo; + marker.ch = start; + marker.color = color; + marker.nameColor = nameColor; + + return marker; + } + }, { + key: 'create_marker', + value: function create_marker(lineNo, start) { + + if (!this.has_marker(lineNo, start)) { + this.init_marker(lineNo, start); + } + + return this.get_marker(lineNo, start); + } + }, { + key: 'init_marker', + value: function init_marker(lineNo, start) { + this.markers[this.key(lineNo, start)] = this.make_element(); + } + }, { + key: 'has_marker', + value: function has_marker(lineNo, start) { + return !!this.get_marker(lineNo, start); + } + }, { + key: 'get_marker', + value: function get_marker(lineNo, start) { + var key = this.key(lineNo, start); + return this.markers[key]; + } + }, { + key: 'update_element', + value: function update_element(el, color) { + el.back_element.style.backgroundColor = color; + } + }, { + key: 'set_mark', + value: function set_mark(line, ch, el) { + this.cm.setBookmark({ line: line, ch: ch }, { widget: el, handleMouseEvents: true }); + } + }, { + key: 'is_excluded_token', + value: function is_excluded_token(line, ch) { + var token = this.cm.getTokenAt({ line: line, ch: ch }, true); + var type = token.type; + var state = token.state.state; + + if (type == null && state == 'block') return true; + if (type == null && state == 'top') return true; + // if (type == null && state == 'prop') return true; + + return this.excluded_token.includes(type); // true is that it has a excluded token + } + }, { + key: 'render', + value: function render(cursor, lineNo, lineHandle, color, nameColor) { + var start = lineHandle.text.indexOf(color, cursor.next); + + if (this.is_excluded_token(lineNo, start + 1) === true) { + // excluded token do not show. + cursor.next = start + color.length; + return; + } + + cursor.next = start + color.length; + + if (this.has_marker(lineNo, start)) { + this.update_element(this.create_marker(lineNo, start), nameColor || color); + this.set_state(lineNo, start, color, nameColor); + return; + } + + var el = this.create_marker(lineNo, start); + + this.update_element(el, nameColor || color); + + this.set_state(lineNo, start, color, nameColor || color); + this.set_mark(lineNo, start, el); + } + }]); + return ColorView; +}(); + +try { + var CodeMirror = require('codemirror'); +} catch (e) {} + +var CHECK_CODEMIRROR_OBJECT = function CHECK_CODEMIRROR_OBJECT() { + return CodeMirror || window.CodeMirror; +}; +function LOAD_CODEMIRROR_COLORPICKER() { + var CODEMIRROR_OBJECT = CHECK_CODEMIRROR_OBJECT(); + + if (CODEMIRROR_OBJECT) { + CODEMIRROR_OBJECT.defineOption("colorpicker", false, function (cm, val, old) { + if (old && old != CODEMIRROR_OBJECT.Init) { + + if (cm.state.colorpicker) { + cm.state.colorpicker.destroy(); + cm.state.colorpicker = null; + } + // remove event listener + } + + if (val) { + cm.state.colorpicker = new ColorView(cm, val); + } + }); + } +} + +LOAD_CODEMIRROR_COLORPICKER(); + +var CodeMirrorExtension = { + load: LOAD_CODEMIRROR_COLORPICKER +}; + +var index = _extends({}, Util, ColorPicker, CodeMirrorExtension); + +return index; + +}))); +window["codemirror-picker"] = self["codemirror-picker"]; diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-mode-css.js b/waterfox/browser/components/sidebar/extlib/codemirror-mode-css.js new file mode 100644 index 000000000000..f8fc101ca1ee --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-mode-css.js @@ -0,0 +1,863 @@ +/* CodeMirror version 5.65.19 */ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/5/LICENSE + +(function(mod) { + if (typeof exports == "object" && typeof module == "object") // CommonJS + mod(require("../../lib/codemirror")); + else if (typeof define == "function" && define.amd) // AMD + define(["../../lib/codemirror"], mod); + else // Plain browser env + mod(CodeMirror); +})(function(CodeMirror) { +"use strict"; + +CodeMirror.defineMode("css", function(config, parserConfig) { + var inline = parserConfig.inline + if (!parserConfig.propertyKeywords) parserConfig = CodeMirror.resolveMode("text/css"); + + var indentUnit = config.indentUnit, + tokenHooks = parserConfig.tokenHooks, + documentTypes = parserConfig.documentTypes || {}, + mediaTypes = parserConfig.mediaTypes || {}, + mediaFeatures = parserConfig.mediaFeatures || {}, + mediaValueKeywords = parserConfig.mediaValueKeywords || {}, + propertyKeywords = parserConfig.propertyKeywords || {}, + nonStandardPropertyKeywords = parserConfig.nonStandardPropertyKeywords || {}, + fontProperties = parserConfig.fontProperties || {}, + counterDescriptors = parserConfig.counterDescriptors || {}, + colorKeywords = parserConfig.colorKeywords || {}, + valueKeywords = parserConfig.valueKeywords || {}, + allowNested = parserConfig.allowNested, + lineComment = parserConfig.lineComment, + supportsAtComponent = parserConfig.supportsAtComponent === true, + highlightNonStandardPropertyKeywords = config.highlightNonStandardPropertyKeywords !== false; + + var type, override; + function ret(style, tp) { type = tp; return style; } + + // Tokenizers + + function tokenBase(stream, state) { + var ch = stream.next(); + if (tokenHooks[ch]) { + var result = tokenHooks[ch](stream, state); + if (result !== false) return result; + } + if (ch == "@") { + stream.eatWhile(/[\w\\\-]/); + return ret("def", stream.current()); + } else if (ch == "=" || (ch == "~" || ch == "|") && stream.eat("=")) { + return ret(null, "compare"); + } else if (ch == "\"" || ch == "'") { + state.tokenize = tokenString(ch); + return state.tokenize(stream, state); + } else if (ch == "#") { + stream.eatWhile(/[\w\\\-]/); + return ret("atom", "hash"); + } else if (ch == "!") { + stream.match(/^\s*\w*/); + return ret("keyword", "important"); + } else if (/\d/.test(ch) || ch == "." && stream.eat(/\d/)) { + stream.eatWhile(/[\w.%]/); + return ret("number", "unit"); + } else if (ch === "-") { + if (/[\d.]/.test(stream.peek())) { + stream.eatWhile(/[\w.%]/); + return ret("number", "unit"); + } else if (stream.match(/^-[\w\\\-]*/)) { + stream.eatWhile(/[\w\\\-]/); + if (stream.match(/^\s*:/, false)) + return ret("variable-2", "variable-definition"); + return ret("variable-2", "variable"); + } else if (stream.match(/^\w+-/)) { + return ret("meta", "meta"); + } + } else if (/[,+>*\/]/.test(ch)) { + return ret(null, "select-op"); + } else if (ch == "." && stream.match(/^-?[_a-z][_a-z0-9-]*/i)) { + return ret("qualifier", "qualifier"); + } else if (/[:;{}\[\]\(\)]/.test(ch)) { + return ret(null, ch); + } else if (stream.match(/^[\w-.]+(?=\()/)) { + if (/^(url(-prefix)?|domain|regexp)$/i.test(stream.current())) { + state.tokenize = tokenParenthesized; + } + return ret("variable callee", "variable"); + } else if (/[\w\\\-]/.test(ch)) { + stream.eatWhile(/[\w\\\-]/); + return ret("property", "word"); + } else { + return ret(null, null); + } + } + + function tokenString(quote) { + return function(stream, state) { + var escaped = false, ch; + while ((ch = stream.next()) != null) { + if (ch == quote && !escaped) { + if (quote == ")") stream.backUp(1); + break; + } + escaped = !escaped && ch == "\\"; + } + if (ch == quote || !escaped && quote != ")") state.tokenize = null; + return ret("string", "string"); + }; + } + + function tokenParenthesized(stream, state) { + stream.next(); // Must be '(' + if (!stream.match(/^\s*[\"\')]/, false)) + state.tokenize = tokenString(")"); + else + state.tokenize = null; + return ret(null, "("); + } + + // Context management + + function Context(type, indent, prev) { + this.type = type; + this.indent = indent; + this.prev = prev; + } + + function pushContext(state, stream, type, indent) { + state.context = new Context(type, stream.indentation() + (indent === false ? 0 : indentUnit), state.context); + return type; + } + + function popContext(state) { + if (state.context.prev) + state.context = state.context.prev; + return state.context.type; + } + + function pass(type, stream, state) { + return states[state.context.type](type, stream, state); + } + function popAndPass(type, stream, state, n) { + for (var i = n || 1; i > 0; i--) + state.context = state.context.prev; + return pass(type, stream, state); + } + + // Parser + + function wordAsValue(stream) { + var word = stream.current().toLowerCase(); + if (valueKeywords.hasOwnProperty(word)) + override = "atom"; + else if (colorKeywords.hasOwnProperty(word)) + override = "keyword"; + else + override = "variable"; + } + + var states = {}; + + states.top = function(type, stream, state) { + if (type == "{") { + return pushContext(state, stream, "block"); + } else if (type == "}" && state.context.prev) { + return popContext(state); + } else if (supportsAtComponent && /@component/i.test(type)) { + return pushContext(state, stream, "atComponentBlock"); + } else if (/^@(-moz-)?document$/i.test(type)) { + return pushContext(state, stream, "documentTypes"); + } else if (/^@(media|supports|(-moz-)?document|import)$/i.test(type)) { + return pushContext(state, stream, "atBlock"); + } else if (/^@(font-face|counter-style)/i.test(type)) { + state.stateArg = type; + return "restricted_atBlock_before"; + } else if (/^@(-(moz|ms|o|webkit)-)?keyframes$/i.test(type)) { + return "keyframes"; + } else if (type && type.charAt(0) == "@") { + return pushContext(state, stream, "at"); + } else if (type == "hash") { + override = "builtin"; + } else if (type == "word") { + override = "tag"; + } else if (type == "variable-definition") { + return "maybeprop"; + } else if (type == "interpolation") { + return pushContext(state, stream, "interpolation"); + } else if (type == ":") { + return "pseudo"; + } else if (allowNested && type == "(") { + return pushContext(state, stream, "parens"); + } + return state.context.type; + }; + + states.block = function(type, stream, state) { + if (type == "word") { + var word = stream.current().toLowerCase(); + if (propertyKeywords.hasOwnProperty(word)) { + override = "property"; + return "maybeprop"; + } else if (nonStandardPropertyKeywords.hasOwnProperty(word)) { + override = highlightNonStandardPropertyKeywords ? "string-2" : "property"; + return "maybeprop"; + } else if (allowNested) { + override = stream.match(/^\s*:(?:\s|$)/, false) ? "property" : "tag"; + return "block"; + } else { + override += " error"; + return "maybeprop"; + } + } else if (type == "meta") { + return "block"; + } else if (!allowNested && (type == "hash" || type == "qualifier")) { + override = "error"; + return "block"; + } else { + return states.top(type, stream, state); + } + }; + + states.maybeprop = function(type, stream, state) { + if (type == ":") return pushContext(state, stream, "prop"); + return pass(type, stream, state); + }; + + states.prop = function(type, stream, state) { + if (type == ";") return popContext(state); + if (type == "{" && allowNested) return pushContext(state, stream, "propBlock"); + if (type == "}" || type == "{") return popAndPass(type, stream, state); + if (type == "(") return pushContext(state, stream, "parens"); + + if (type == "hash" && !/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/.test(stream.current())) { + override += " error"; + } else if (type == "word") { + wordAsValue(stream); + } else if (type == "interpolation") { + return pushContext(state, stream, "interpolation"); + } + return "prop"; + }; + + states.propBlock = function(type, _stream, state) { + if (type == "}") return popContext(state); + if (type == "word") { override = "property"; return "maybeprop"; } + return state.context.type; + }; + + states.parens = function(type, stream, state) { + if (type == "{" || type == "}") return popAndPass(type, stream, state); + if (type == ")") return popContext(state); + if (type == "(") return pushContext(state, stream, "parens"); + if (type == "interpolation") return pushContext(state, stream, "interpolation"); + if (type == "word") wordAsValue(stream); + return "parens"; + }; + + states.pseudo = function(type, stream, state) { + if (type == "meta") return "pseudo"; + + if (type == "word") { + override = "variable-3"; + return state.context.type; + } + return pass(type, stream, state); + }; + + states.documentTypes = function(type, stream, state) { + if (type == "word" && documentTypes.hasOwnProperty(stream.current())) { + override = "tag"; + return state.context.type; + } else { + return states.atBlock(type, stream, state); + } + }; + + states.atBlock = function(type, stream, state) { + if (type == "(") return pushContext(state, stream, "atBlock_parens"); + if (type == "}" || type == ";") return popAndPass(type, stream, state); + if (type == "{") return popContext(state) && pushContext(state, stream, allowNested ? "block" : "top"); + + if (type == "interpolation") return pushContext(state, stream, "interpolation"); + + if (type == "word") { + var word = stream.current().toLowerCase(); + if (word == "only" || word == "not" || word == "and" || word == "or") + override = "keyword"; + else if (mediaTypes.hasOwnProperty(word)) + override = "attribute"; + else if (mediaFeatures.hasOwnProperty(word)) + override = "property"; + else if (mediaValueKeywords.hasOwnProperty(word)) + override = "keyword"; + else if (propertyKeywords.hasOwnProperty(word)) + override = "property"; + else if (nonStandardPropertyKeywords.hasOwnProperty(word)) + override = highlightNonStandardPropertyKeywords ? "string-2" : "property"; + else if (valueKeywords.hasOwnProperty(word)) + override = "atom"; + else if (colorKeywords.hasOwnProperty(word)) + override = "keyword"; + else + override = "error"; + } + return state.context.type; + }; + + states.atComponentBlock = function(type, stream, state) { + if (type == "}") + return popAndPass(type, stream, state); + if (type == "{") + return popContext(state) && pushContext(state, stream, allowNested ? "block" : "top", false); + if (type == "word") + override = "error"; + return state.context.type; + }; + + states.atBlock_parens = function(type, stream, state) { + if (type == ")") return popContext(state); + if (type == "{" || type == "}") return popAndPass(type, stream, state, 2); + return states.atBlock(type, stream, state); + }; + + states.restricted_atBlock_before = function(type, stream, state) { + if (type == "{") + return pushContext(state, stream, "restricted_atBlock"); + if (type == "word" && state.stateArg == "@counter-style") { + override = "variable"; + return "restricted_atBlock_before"; + } + return pass(type, stream, state); + }; + + states.restricted_atBlock = function(type, stream, state) { + if (type == "}") { + state.stateArg = null; + return popContext(state); + } + if (type == "word") { + if ((state.stateArg == "@font-face" && !fontProperties.hasOwnProperty(stream.current().toLowerCase())) || + (state.stateArg == "@counter-style" && !counterDescriptors.hasOwnProperty(stream.current().toLowerCase()))) + override = "error"; + else + override = "property"; + return "maybeprop"; + } + return "restricted_atBlock"; + }; + + states.keyframes = function(type, stream, state) { + if (type == "word") { override = "variable"; return "keyframes"; } + if (type == "{") return pushContext(state, stream, "top"); + return pass(type, stream, state); + }; + + states.at = function(type, stream, state) { + if (type == ";") return popContext(state); + if (type == "{" || type == "}") return popAndPass(type, stream, state); + if (type == "word") override = "tag"; + else if (type == "hash") override = "builtin"; + return "at"; + }; + + states.interpolation = function(type, stream, state) { + if (type == "}") return popContext(state); + if (type == "{" || type == ";") return popAndPass(type, stream, state); + if (type == "word") override = "variable"; + else if (type != "variable" && type != "(" && type != ")") override = "error"; + return "interpolation"; + }; + + return { + startState: function(base) { + return {tokenize: null, + state: inline ? "block" : "top", + stateArg: null, + context: new Context(inline ? "block" : "top", base || 0, null)}; + }, + + token: function(stream, state) { + if (!state.tokenize && stream.eatSpace()) return null; + var style = (state.tokenize || tokenBase)(stream, state); + if (style && typeof style == "object") { + type = style[1]; + style = style[0]; + } + override = style; + if (type != "comment") + state.state = states[state.state](type, stream, state); + return override; + }, + + indent: function(state, textAfter) { + var cx = state.context, ch = textAfter && textAfter.charAt(0); + var indent = cx.indent; + if (cx.type == "prop" && (ch == "}" || ch == ")")) cx = cx.prev; + if (cx.prev) { + if (ch == "}" && (cx.type == "block" || cx.type == "top" || + cx.type == "interpolation" || cx.type == "restricted_atBlock")) { + // Resume indentation from parent context. + cx = cx.prev; + indent = cx.indent; + } else if (ch == ")" && (cx.type == "parens" || cx.type == "atBlock_parens") || + ch == "{" && (cx.type == "at" || cx.type == "atBlock")) { + // Dedent relative to current context. + indent = Math.max(0, cx.indent - indentUnit); + } + } + return indent; + }, + + electricChars: "}", + blockCommentStart: "/*", + blockCommentEnd: "*/", + blockCommentContinue: " * ", + lineComment: lineComment, + fold: "brace" + }; +}); + + function keySet(array) { + var keys = {}; + for (var i = 0; i < array.length; ++i) { + keys[array[i].toLowerCase()] = true; + } + return keys; + } + + var documentTypes_ = [ + "domain", "regexp", "url", "url-prefix" + ], documentTypes = keySet(documentTypes_); + + var mediaTypes_ = [ + "all", "aural", "braille", "handheld", "print", "projection", "screen", + "tty", "tv", "embossed" + ], mediaTypes = keySet(mediaTypes_); + + var mediaFeatures_ = [ + "width", "min-width", "max-width", "height", "min-height", "max-height", + "device-width", "min-device-width", "max-device-width", "device-height", + "min-device-height", "max-device-height", "aspect-ratio", + "min-aspect-ratio", "max-aspect-ratio", "device-aspect-ratio", + "min-device-aspect-ratio", "max-device-aspect-ratio", "color", "min-color", + "max-color", "color-index", "min-color-index", "max-color-index", + "monochrome", "min-monochrome", "max-monochrome", "resolution", + "min-resolution", "max-resolution", "scan", "grid", "orientation", + "device-pixel-ratio", "min-device-pixel-ratio", "max-device-pixel-ratio", + "pointer", "any-pointer", "hover", "any-hover", "prefers-color-scheme", + "dynamic-range", "video-dynamic-range" + ], mediaFeatures = keySet(mediaFeatures_); + + var mediaValueKeywords_ = [ + "landscape", "portrait", "none", "coarse", "fine", "on-demand", "hover", + "interlace", "progressive", + "dark", "light", + "standard", "high" + ], mediaValueKeywords = keySet(mediaValueKeywords_); + + var propertyKeywords_ = [ + "align-content", "align-items", "align-self", "alignment-adjust", + "alignment-baseline", "all", "anchor-point", "animation", "animation-delay", + "animation-direction", "animation-duration", "animation-fill-mode", + "animation-iteration-count", "animation-name", "animation-play-state", + "animation-timing-function", "appearance", "azimuth", "backdrop-filter", + "backface-visibility", "background", "background-attachment", + "background-blend-mode", "background-clip", "background-color", + "background-image", "background-origin", "background-position", + "background-position-x", "background-position-y", "background-repeat", + "background-size", "baseline-shift", "binding", "bleed", "block-size", + "bookmark-label", "bookmark-level", "bookmark-state", "bookmark-target", + "border", "border-bottom", "border-bottom-color", "border-bottom-left-radius", + "border-bottom-right-radius", "border-bottom-style", "border-bottom-width", + "border-collapse", "border-color", "border-image", "border-image-outset", + "border-image-repeat", "border-image-slice", "border-image-source", + "border-image-width", "border-left", "border-left-color", "border-left-style", + "border-left-width", "border-radius", "border-right", "border-right-color", + "border-right-style", "border-right-width", "border-spacing", "border-style", + "border-top", "border-top-color", "border-top-left-radius", + "border-top-right-radius", "border-top-style", "border-top-width", + "border-width", "bottom", "box-decoration-break", "box-shadow", "box-sizing", + "break-after", "break-before", "break-inside", "caption-side", "caret-color", + "clear", "clip", "color", "color-profile", "column-count", "column-fill", + "column-gap", "column-rule", "column-rule-color", "column-rule-style", + "column-rule-width", "column-span", "column-width", "columns", "contain", + "content", "counter-increment", "counter-reset", "crop", "cue", "cue-after", + "cue-before", "cursor", "direction", "display", "dominant-baseline", + "drop-initial-after-adjust", "drop-initial-after-align", + "drop-initial-before-adjust", "drop-initial-before-align", "drop-initial-size", + "drop-initial-value", "elevation", "empty-cells", "fit", "fit-content", "fit-position", + "flex", "flex-basis", "flex-direction", "flex-flow", "flex-grow", + "flex-shrink", "flex-wrap", "float", "float-offset", "flow-from", "flow-into", + "font", "font-family", "font-feature-settings", "font-kerning", + "font-language-override", "font-optical-sizing", "font-size", + "font-size-adjust", "font-stretch", "font-style", "font-synthesis", + "font-variant", "font-variant-alternates", "font-variant-caps", + "font-variant-east-asian", "font-variant-ligatures", "font-variant-numeric", + "font-variant-position", "font-variation-settings", "font-weight", "gap", + "grid", "grid-area", "grid-auto-columns", "grid-auto-flow", "grid-auto-rows", + "grid-column", "grid-column-end", "grid-column-gap", "grid-column-start", + "grid-gap", "grid-row", "grid-row-end", "grid-row-gap", "grid-row-start", + "grid-template", "grid-template-areas", "grid-template-columns", + "grid-template-rows", "hanging-punctuation", "height", "hyphens", "icon", + "image-orientation", "image-rendering", "image-resolution", "inline-box-align", + "inset", "inset-block", "inset-block-end", "inset-block-start", "inset-inline", + "inset-inline-end", "inset-inline-start", "isolation", "justify-content", + "justify-items", "justify-self", "left", "letter-spacing", "line-break", + "line-height", "line-height-step", "line-stacking", "line-stacking-ruby", + "line-stacking-shift", "line-stacking-strategy", "list-style", + "list-style-image", "list-style-position", "list-style-type", "margin", + "margin-bottom", "margin-left", "margin-right", "margin-top", "marks", + "marquee-direction", "marquee-loop", "marquee-play-count", "marquee-speed", + "marquee-style", "mask-clip", "mask-composite", "mask-image", "mask-mode", + "mask-origin", "mask-position", "mask-repeat", "mask-size","mask-type", + "max-block-size", "max-height", "max-inline-size", + "max-width", "min-block-size", "min-height", "min-inline-size", "min-width", + "mix-blend-mode", "move-to", "nav-down", "nav-index", "nav-left", "nav-right", + "nav-up", "object-fit", "object-position", "offset", "offset-anchor", + "offset-distance", "offset-path", "offset-position", "offset-rotate", + "opacity", "order", "orphans", "outline", "outline-color", "outline-offset", + "outline-style", "outline-width", "overflow", "overflow-style", + "overflow-wrap", "overflow-x", "overflow-y", "padding", "padding-bottom", + "padding-left", "padding-right", "padding-top", "page", "page-break-after", + "page-break-before", "page-break-inside", "page-policy", "pause", + "pause-after", "pause-before", "perspective", "perspective-origin", "pitch", + "pitch-range", "place-content", "place-items", "place-self", "play-during", + "position", "presentation-level", "punctuation-trim", "quotes", + "region-break-after", "region-break-before", "region-break-inside", + "region-fragment", "rendering-intent", "resize", "rest", "rest-after", + "rest-before", "richness", "right", "rotate", "rotation", "rotation-point", + "row-gap", "ruby-align", "ruby-overhang", "ruby-position", "ruby-span", + "scale", "scroll-behavior", "scroll-margin", "scroll-margin-block", + "scroll-margin-block-end", "scroll-margin-block-start", "scroll-margin-bottom", + "scroll-margin-inline", "scroll-margin-inline-end", + "scroll-margin-inline-start", "scroll-margin-left", "scroll-margin-right", + "scroll-margin-top", "scroll-padding", "scroll-padding-block", + "scroll-padding-block-end", "scroll-padding-block-start", + "scroll-padding-bottom", "scroll-padding-inline", "scroll-padding-inline-end", + "scroll-padding-inline-start", "scroll-padding-left", "scroll-padding-right", + "scroll-padding-top", "scroll-snap-align", "scroll-snap-type", + "shape-image-threshold", "shape-inside", "shape-margin", "shape-outside", + "size", "speak", "speak-as", "speak-header", "speak-numeral", + "speak-punctuation", "speech-rate", "stress", "string-set", "tab-size", + "table-layout", "target", "target-name", "target-new", "target-position", + "text-align", "text-align-last", "text-combine-upright", "text-decoration", + "text-decoration-color", "text-decoration-line", "text-decoration-skip", + "text-decoration-skip-ink", "text-decoration-style", "text-emphasis", + "text-emphasis-color", "text-emphasis-position", "text-emphasis-style", + "text-height", "text-indent", "text-justify", "text-orientation", + "text-outline", "text-overflow", "text-rendering", "text-shadow", + "text-size-adjust", "text-space-collapse", "text-transform", + "text-underline-position", "text-wrap", "top", "touch-action", "transform", "transform-origin", + "transform-style", "transition", "transition-delay", "transition-duration", + "transition-property", "transition-timing-function", "translate", + "unicode-bidi", "user-select", "vertical-align", "visibility", "voice-balance", + "voice-duration", "voice-family", "voice-pitch", "voice-range", "voice-rate", + "voice-stress", "voice-volume", "volume", "white-space", "widows", "width", + "will-change", "word-break", "word-spacing", "word-wrap", "writing-mode", "z-index", + // SVG-specific + "clip-path", "clip-rule", "mask", "enable-background", "filter", "flood-color", + "flood-opacity", "lighting-color", "stop-color", "stop-opacity", "pointer-events", + "color-interpolation", "color-interpolation-filters", + "color-rendering", "fill", "fill-opacity", "fill-rule", "image-rendering", + "marker", "marker-end", "marker-mid", "marker-start", "paint-order", "shape-rendering", "stroke", + "stroke-dasharray", "stroke-dashoffset", "stroke-linecap", "stroke-linejoin", + "stroke-miterlimit", "stroke-opacity", "stroke-width", "text-rendering", + "baseline-shift", "dominant-baseline", "glyph-orientation-horizontal", + "glyph-orientation-vertical", "text-anchor", "writing-mode", + ], propertyKeywords = keySet(propertyKeywords_); + + var nonStandardPropertyKeywords_ = [ + "accent-color", "aspect-ratio", "border-block", "border-block-color", "border-block-end", + "border-block-end-color", "border-block-end-style", "border-block-end-width", + "border-block-start", "border-block-start-color", "border-block-start-style", + "border-block-start-width", "border-block-style", "border-block-width", + "border-inline", "border-inline-color", "border-inline-end", + "border-inline-end-color", "border-inline-end-style", + "border-inline-end-width", "border-inline-start", "border-inline-start-color", + "border-inline-start-style", "border-inline-start-width", + "border-inline-style", "border-inline-width", "content-visibility", "margin-block", + "margin-block-end", "margin-block-start", "margin-inline", "margin-inline-end", + "margin-inline-start", "overflow-anchor", "overscroll-behavior", "padding-block", "padding-block-end", + "padding-block-start", "padding-inline", "padding-inline-end", + "padding-inline-start", "scroll-snap-stop", "scrollbar-3d-light-color", + "scrollbar-arrow-color", "scrollbar-base-color", "scrollbar-dark-shadow-color", + "scrollbar-face-color", "scrollbar-highlight-color", "scrollbar-shadow-color", + "scrollbar-track-color", "searchfield-cancel-button", "searchfield-decoration", + "searchfield-results-button", "searchfield-results-decoration", "shape-inside", "zoom" + ], nonStandardPropertyKeywords = keySet(nonStandardPropertyKeywords_); + + var fontProperties_ = [ + "font-display", "font-family", "src", "unicode-range", "font-variant", + "font-feature-settings", "font-stretch", "font-weight", "font-style" + ], fontProperties = keySet(fontProperties_); + + var counterDescriptors_ = [ + "additive-symbols", "fallback", "negative", "pad", "prefix", "range", + "speak-as", "suffix", "symbols", "system" + ], counterDescriptors = keySet(counterDescriptors_); + + var colorKeywords_ = [ + "aliceblue", "antiquewhite", "aqua", "aquamarine", "azure", "beige", + "bisque", "black", "blanchedalmond", "blue", "blueviolet", "brown", + "burlywood", "cadetblue", "chartreuse", "chocolate", "coral", "cornflowerblue", + "cornsilk", "crimson", "cyan", "darkblue", "darkcyan", "darkgoldenrod", + "darkgray", "darkgreen", "darkgrey", "darkkhaki", "darkmagenta", "darkolivegreen", + "darkorange", "darkorchid", "darkred", "darksalmon", "darkseagreen", + "darkslateblue", "darkslategray", "darkslategrey", "darkturquoise", "darkviolet", + "deeppink", "deepskyblue", "dimgray", "dimgrey", "dodgerblue", "firebrick", + "floralwhite", "forestgreen", "fuchsia", "gainsboro", "ghostwhite", + "gold", "goldenrod", "gray", "grey", "green", "greenyellow", "honeydew", + "hotpink", "indianred", "indigo", "ivory", "khaki", "lavender", + "lavenderblush", "lawngreen", "lemonchiffon", "lightblue", "lightcoral", + "lightcyan", "lightgoldenrodyellow", "lightgray", "lightgreen", "lightgrey", "lightpink", + "lightsalmon", "lightseagreen", "lightskyblue", "lightslategray", "lightslategrey", + "lightsteelblue", "lightyellow", "lime", "limegreen", "linen", "magenta", + "maroon", "mediumaquamarine", "mediumblue", "mediumorchid", "mediumpurple", + "mediumseagreen", "mediumslateblue", "mediumspringgreen", "mediumturquoise", + "mediumvioletred", "midnightblue", "mintcream", "mistyrose", "moccasin", + "navajowhite", "navy", "oldlace", "olive", "olivedrab", "orange", "orangered", + "orchid", "palegoldenrod", "palegreen", "paleturquoise", "palevioletred", + "papayawhip", "peachpuff", "peru", "pink", "plum", "powderblue", + "purple", "rebeccapurple", "red", "rosybrown", "royalblue", "saddlebrown", + "salmon", "sandybrown", "seagreen", "seashell", "sienna", "silver", "skyblue", + "slateblue", "slategray", "slategrey", "snow", "springgreen", "steelblue", "tan", + "teal", "thistle", "tomato", "turquoise", "violet", "wheat", "white", + "whitesmoke", "yellow", "yellowgreen" + ], colorKeywords = keySet(colorKeywords_); + + var valueKeywords_ = [ + "above", "absolute", "activeborder", "additive", "activecaption", "afar", + "after-white-space", "ahead", "alias", "all", "all-scroll", "alphabetic", "alternate", + "always", "amharic", "amharic-abegede", "antialiased", "appworkspace", + "arabic-indic", "armenian", "asterisks", "attr", "auto", "auto-flow", "avoid", "avoid-column", "avoid-page", + "avoid-region", "axis-pan", "background", "backwards", "baseline", "below", "bidi-override", "binary", + "bengali", "blink", "block", "block-axis", "blur", "bold", "bolder", "border", "border-box", + "both", "bottom", "break", "break-all", "break-word", "brightness", "bullets", "button", + "buttonface", "buttonhighlight", "buttonshadow", "buttontext", "calc", "cambodian", + "capitalize", "caps-lock-indicator", "caption", "captiontext", "caret", + "cell", "center", "checkbox", "circle", "cjk-decimal", "cjk-earthly-branch", + "cjk-heavenly-stem", "cjk-ideographic", "clear", "clip", "close-quote", + "col-resize", "collapse", "color", "color-burn", "color-dodge", "column", "column-reverse", + "compact", "condensed", "conic-gradient", "contain", "content", "contents", + "content-box", "context-menu", "continuous", "contrast", "copy", "counter", "counters", "cover", "crop", + "cross", "crosshair", "cubic-bezier", "currentcolor", "cursive", "cyclic", "darken", "dashed", "decimal", + "decimal-leading-zero", "default", "default-button", "dense", "destination-atop", + "destination-in", "destination-out", "destination-over", "devanagari", "difference", + "disc", "discard", "disclosure-closed", "disclosure-open", "document", + "dot-dash", "dot-dot-dash", + "dotted", "double", "down", "drop-shadow", "e-resize", "ease", "ease-in", "ease-in-out", "ease-out", + "element", "ellipse", "ellipsis", "embed", "end", "ethiopic", "ethiopic-abegede", + "ethiopic-abegede-am-et", "ethiopic-abegede-gez", "ethiopic-abegede-ti-er", + "ethiopic-abegede-ti-et", "ethiopic-halehame-aa-er", + "ethiopic-halehame-aa-et", "ethiopic-halehame-am-et", + "ethiopic-halehame-gez", "ethiopic-halehame-om-et", + "ethiopic-halehame-sid-et", "ethiopic-halehame-so-et", + "ethiopic-halehame-ti-er", "ethiopic-halehame-ti-et", "ethiopic-halehame-tig", + "ethiopic-numeric", "ew-resize", "exclusion", "expanded", "extends", "extra-condensed", + "extra-expanded", "fantasy", "fast", "fill", "fill-box", "fixed", "flat", "flex", "flex-end", "flex-start", "footnotes", + "forwards", "from", "geometricPrecision", "georgian", "grayscale", "graytext", "grid", "groove", + "gujarati", "gurmukhi", "hand", "hangul", "hangul-consonant", "hard-light", "hebrew", + "help", "hidden", "hide", "higher", "highlight", "highlighttext", + "hiragana", "hiragana-iroha", "horizontal", "hsl", "hsla", "hue", "hue-rotate", "icon", "ignore", + "inactiveborder", "inactivecaption", "inactivecaptiontext", "infinite", + "infobackground", "infotext", "inherit", "initial", "inline", "inline-axis", + "inline-block", "inline-flex", "inline-grid", "inline-table", "inset", "inside", "intrinsic", "invert", + "italic", "japanese-formal", "japanese-informal", "justify", "kannada", + "katakana", "katakana-iroha", "keep-all", "khmer", + "korean-hangul-formal", "korean-hanja-formal", "korean-hanja-informal", + "landscape", "lao", "large", "larger", "left", "level", "lighter", "lighten", + "line-through", "linear", "linear-gradient", "lines", "list-item", "listbox", "listitem", + "local", "logical", "loud", "lower", "lower-alpha", "lower-armenian", + "lower-greek", "lower-hexadecimal", "lower-latin", "lower-norwegian", + "lower-roman", "lowercase", "ltr", "luminosity", "malayalam", "manipulation", "match", "matrix", "matrix3d", + "media-play-button", "media-slider", "media-sliderthumb", + "media-volume-slider", "media-volume-sliderthumb", "medium", + "menu", "menulist", "menulist-button", + "menutext", "message-box", "middle", "min-intrinsic", + "mix", "mongolian", "monospace", "move", "multiple", "multiple_mask_images", "multiply", "myanmar", "n-resize", + "narrower", "ne-resize", "nesw-resize", "no-close-quote", "no-drop", + "no-open-quote", "no-repeat", "none", "normal", "not-allowed", "nowrap", + "ns-resize", "numbers", "numeric", "nw-resize", "nwse-resize", "oblique", "octal", "opacity", "open-quote", + "optimizeLegibility", "optimizeSpeed", "oriya", "oromo", "outset", + "outside", "outside-shape", "overlay", "overline", "padding", "padding-box", + "painted", "page", "paused", "persian", "perspective", "pinch-zoom", "plus-darker", "plus-lighter", + "pointer", "polygon", "portrait", "pre", "pre-line", "pre-wrap", "preserve-3d", + "progress", "push-button", "radial-gradient", "radio", "read-only", + "read-write", "read-write-plaintext-only", "rectangle", "region", + "relative", "repeat", "repeating-linear-gradient", "repeating-radial-gradient", + "repeating-conic-gradient", "repeat-x", "repeat-y", "reset", "reverse", + "rgb", "rgba", "ridge", "right", "rotate", "rotate3d", "rotateX", "rotateY", + "rotateZ", "round", "row", "row-resize", "row-reverse", "rtl", "run-in", "running", + "s-resize", "sans-serif", "saturate", "saturation", "scale", "scale3d", "scaleX", "scaleY", "scaleZ", "screen", + "scroll", "scrollbar", "scroll-position", "se-resize", "searchfield", + "searchfield-cancel-button", "searchfield-decoration", + "searchfield-results-button", "searchfield-results-decoration", "self-start", "self-end", + "semi-condensed", "semi-expanded", "separate", "sepia", "serif", "show", "sidama", + "simp-chinese-formal", "simp-chinese-informal", "single", + "skew", "skewX", "skewY", "skip-white-space", "slide", "slider-horizontal", + "slider-vertical", "sliderthumb-horizontal", "sliderthumb-vertical", "slow", + "small", "small-caps", "small-caption", "smaller", "soft-light", "solid", "somali", + "source-atop", "source-in", "source-out", "source-over", "space", "space-around", "space-between", "space-evenly", "spell-out", "square", + "square-button", "start", "static", "status-bar", "stretch", "stroke", "stroke-box", "sub", + "subpixel-antialiased", "svg_masks", "super", "sw-resize", "symbolic", "symbols", "system-ui", "table", + "table-caption", "table-cell", "table-column", "table-column-group", + "table-footer-group", "table-header-group", "table-row", "table-row-group", + "tamil", + "telugu", "text", "text-bottom", "text-top", "textarea", "textfield", "thai", + "thick", "thin", "threeddarkshadow", "threedface", "threedhighlight", + "threedlightshadow", "threedshadow", "tibetan", "tigre", "tigrinya-er", + "tigrinya-er-abegede", "tigrinya-et", "tigrinya-et-abegede", "to", "top", + "trad-chinese-formal", "trad-chinese-informal", "transform", + "translate", "translate3d", "translateX", "translateY", "translateZ", + "transparent", "ultra-condensed", "ultra-expanded", "underline", "unidirectional-pan", "unset", "up", + "upper-alpha", "upper-armenian", "upper-greek", "upper-hexadecimal", + "upper-latin", "upper-norwegian", "upper-roman", "uppercase", "urdu", "url", + "var", "vertical", "vertical-text", "view-box", "visible", "visibleFill", "visiblePainted", + "visibleStroke", "visual", "w-resize", "wait", "wave", "wider", + "window", "windowframe", "windowtext", "words", "wrap", "wrap-reverse", "x-large", "x-small", "xor", + "xx-large", "xx-small" + ], valueKeywords = keySet(valueKeywords_); + + var allWords = documentTypes_.concat(mediaTypes_).concat(mediaFeatures_).concat(mediaValueKeywords_) + .concat(propertyKeywords_).concat(nonStandardPropertyKeywords_).concat(colorKeywords_) + .concat(valueKeywords_); + CodeMirror.registerHelper("hintWords", "css", allWords); + + function tokenCComment(stream, state) { + var maybeEnd = false, ch; + while ((ch = stream.next()) != null) { + if (maybeEnd && ch == "/") { + state.tokenize = null; + break; + } + maybeEnd = (ch == "*"); + } + return ["comment", "comment"]; + } + + CodeMirror.defineMIME("text/css", { + documentTypes: documentTypes, + mediaTypes: mediaTypes, + mediaFeatures: mediaFeatures, + mediaValueKeywords: mediaValueKeywords, + propertyKeywords: propertyKeywords, + nonStandardPropertyKeywords: nonStandardPropertyKeywords, + fontProperties: fontProperties, + counterDescriptors: counterDescriptors, + colorKeywords: colorKeywords, + valueKeywords: valueKeywords, + tokenHooks: { + "/": function(stream, state) { + if (!stream.eat("*")) return false; + state.tokenize = tokenCComment; + return tokenCComment(stream, state); + } + }, + name: "css" + }); + + CodeMirror.defineMIME("text/x-scss", { + mediaTypes: mediaTypes, + mediaFeatures: mediaFeatures, + mediaValueKeywords: mediaValueKeywords, + propertyKeywords: propertyKeywords, + nonStandardPropertyKeywords: nonStandardPropertyKeywords, + colorKeywords: colorKeywords, + valueKeywords: valueKeywords, + fontProperties: fontProperties, + allowNested: true, + lineComment: "//", + tokenHooks: { + "/": function(stream, state) { + if (stream.eat("/")) { + stream.skipToEnd(); + return ["comment", "comment"]; + } else if (stream.eat("*")) { + state.tokenize = tokenCComment; + return tokenCComment(stream, state); + } else { + return ["operator", "operator"]; + } + }, + ":": function(stream) { + if (stream.match(/^\s*\{/, false)) + return [null, null] + return false; + }, + "$": function(stream) { + stream.match(/^[\w-]+/); + if (stream.match(/^\s*:/, false)) + return ["variable-2", "variable-definition"]; + return ["variable-2", "variable"]; + }, + "#": function(stream) { + if (!stream.eat("{")) return false; + return [null, "interpolation"]; + } + }, + name: "css", + helperType: "scss" + }); + + CodeMirror.defineMIME("text/x-less", { + mediaTypes: mediaTypes, + mediaFeatures: mediaFeatures, + mediaValueKeywords: mediaValueKeywords, + propertyKeywords: propertyKeywords, + nonStandardPropertyKeywords: nonStandardPropertyKeywords, + colorKeywords: colorKeywords, + valueKeywords: valueKeywords, + fontProperties: fontProperties, + allowNested: true, + lineComment: "//", + tokenHooks: { + "/": function(stream, state) { + if (stream.eat("/")) { + stream.skipToEnd(); + return ["comment", "comment"]; + } else if (stream.eat("*")) { + state.tokenize = tokenCComment; + return tokenCComment(stream, state); + } else { + return ["operator", "operator"]; + } + }, + "@": function(stream) { + if (stream.eat("{")) return [null, "interpolation"]; + if (stream.match(/^(charset|document|font-face|import|(-(moz|ms|o|webkit)-)?keyframes|media|namespace|page|supports)\b/i, false)) return false; + stream.eatWhile(/[\w\\\-]/); + if (stream.match(/^\s*:/, false)) + return ["variable-2", "variable-definition"]; + return ["variable-2", "variable"]; + }, + "&": function() { + return ["atom", "atom"]; + } + }, + name: "css", + helperType: "less" + }); + + CodeMirror.defineMIME("text/x-gss", { + documentTypes: documentTypes, + mediaTypes: mediaTypes, + mediaFeatures: mediaFeatures, + propertyKeywords: propertyKeywords, + nonStandardPropertyKeywords: nonStandardPropertyKeywords, + fontProperties: fontProperties, + counterDescriptors: counterDescriptors, + colorKeywords: colorKeywords, + valueKeywords: valueKeywords, + supportsAtComponent: true, + tokenHooks: { + "/": function(stream, state) { + if (!stream.eat("*")) return false; + state.tokenize = tokenCComment; + return tokenCComment(stream, state); + } + }, + name: "css", + helperType: "gss" + }); + +}); diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/3024-day.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/3024-day.css new file mode 100644 index 000000000000..71326553062d --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/3024-day.css @@ -0,0 +1,41 @@ +/* + + Name: 3024 day + Author: Jan T. Sott (http://github.com/idleberg) + + CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror) + Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) + +*/ + +.cm-s-3024-day.CodeMirror { background: #f7f7f7; color: #3a3432; } +.cm-s-3024-day div.CodeMirror-selected { background: #d6d5d4; } + +.cm-s-3024-day .CodeMirror-line::selection, .cm-s-3024-day .CodeMirror-line > span::selection, .cm-s-3024-day .CodeMirror-line > span > span::selection { background: #d6d5d4; } +.cm-s-3024-day .CodeMirror-line::-moz-selection, .cm-s-3024-day .CodeMirror-line > span::-moz-selection, .cm-s-3024-day .CodeMirror-line > span > span::selection { background: #d9d9d9; } + +.cm-s-3024-day .CodeMirror-gutters { background: #f7f7f7; border-right: 0px; } +.cm-s-3024-day .CodeMirror-guttermarker { color: #db2d20; } +.cm-s-3024-day .CodeMirror-guttermarker-subtle { color: #807d7c; } +.cm-s-3024-day .CodeMirror-linenumber { color: #807d7c; } + +.cm-s-3024-day .CodeMirror-cursor { border-left: 1px solid #5c5855; } + +.cm-s-3024-day span.cm-comment { color: #cdab53; } +.cm-s-3024-day span.cm-atom { color: #a16a94; } +.cm-s-3024-day span.cm-number { color: #a16a94; } + +.cm-s-3024-day span.cm-property, .cm-s-3024-day span.cm-attribute { color: #01a252; } +.cm-s-3024-day span.cm-keyword { color: #db2d20; } +.cm-s-3024-day span.cm-string { color: #fded02; } + +.cm-s-3024-day span.cm-variable { color: #01a252; } +.cm-s-3024-day span.cm-variable-2 { color: #01a0e4; } +.cm-s-3024-day span.cm-def { color: #e8bbd0; } +.cm-s-3024-day span.cm-bracket { color: #3a3432; } +.cm-s-3024-day span.cm-tag { color: #db2d20; } +.cm-s-3024-day span.cm-link { color: #a16a94; } +.cm-s-3024-day span.cm-error { background: #db2d20; color: #5c5855; } + +.cm-s-3024-day .CodeMirror-activeline-background { background: #e8f2ff; } +.cm-s-3024-day .CodeMirror-matchingbracket { text-decoration: underline; color: #a16a94 !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/3024-night.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/3024-night.css new file mode 100644 index 000000000000..adc5900ad10d --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/3024-night.css @@ -0,0 +1,39 @@ +/* + + Name: 3024 night + Author: Jan T. Sott (http://github.com/idleberg) + + CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror) + Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) + +*/ + +.cm-s-3024-night.CodeMirror { background: #090300; color: #d6d5d4; } +.cm-s-3024-night div.CodeMirror-selected { background: #3a3432; } +.cm-s-3024-night .CodeMirror-line::selection, .cm-s-3024-night .CodeMirror-line > span::selection, .cm-s-3024-night .CodeMirror-line > span > span::selection { background: rgba(58, 52, 50, .99); } +.cm-s-3024-night .CodeMirror-line::-moz-selection, .cm-s-3024-night .CodeMirror-line > span::-moz-selection, .cm-s-3024-night .CodeMirror-line > span > span::-moz-selection { background: rgba(58, 52, 50, .99); } +.cm-s-3024-night .CodeMirror-gutters { background: #090300; border-right: 0px; } +.cm-s-3024-night .CodeMirror-guttermarker { color: #db2d20; } +.cm-s-3024-night .CodeMirror-guttermarker-subtle { color: #5c5855; } +.cm-s-3024-night .CodeMirror-linenumber { color: #5c5855; } + +.cm-s-3024-night .CodeMirror-cursor { border-left: 1px solid #807d7c; } + +.cm-s-3024-night span.cm-comment { color: #cdab53; } +.cm-s-3024-night span.cm-atom { color: #a16a94; } +.cm-s-3024-night span.cm-number { color: #a16a94; } + +.cm-s-3024-night span.cm-property, .cm-s-3024-night span.cm-attribute { color: #01a252; } +.cm-s-3024-night span.cm-keyword { color: #db2d20; } +.cm-s-3024-night span.cm-string { color: #fded02; } + +.cm-s-3024-night span.cm-variable { color: #01a252; } +.cm-s-3024-night span.cm-variable-2 { color: #01a0e4; } +.cm-s-3024-night span.cm-def { color: #e8bbd0; } +.cm-s-3024-night span.cm-bracket { color: #d6d5d4; } +.cm-s-3024-night span.cm-tag { color: #db2d20; } +.cm-s-3024-night span.cm-link { color: #a16a94; } +.cm-s-3024-night span.cm-error { background: #db2d20; color: #807d7c; } + +.cm-s-3024-night .CodeMirror-activeline-background { background: #2F2F2F; } +.cm-s-3024-night .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/abbott.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/abbott.css new file mode 100644 index 000000000000..3e516a67f948 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/abbott.css @@ -0,0 +1,268 @@ +/* + * abbott.css + * A warm, dark theme for prose and code, with pastels and pretty greens. + * + * Ported from abbott.vim (https://github.com/bcat/abbott.vim) version 2.1. + * Original design and CodeMirror port by Jonathan Rascher. + * + * This theme shares the following color palette with the Vim color scheme. + * + * Brown shades: + * bistre: #231c14 + * chocolate: #3c3022 + * cocoa: #745d42 + * vanilla_cream: #fef3b4 + * + * Red shades: + * crimson: #d80450 + * cinnabar: #f63f05 + * + * Green shades: + * dark_olive: #273900 + * forest_green: #24a507 + * chartreuse: #a0ea00 + * pastel_chartreuse: #d8ff84 + * + * Yellow shades: + * marigold: #fbb32f + * lemon_meringue: #fbec5d + * + * Blue shades: + * cornflower_blue: #3f91f1 + * periwinkle_blue: #8ccdf0 + * + * Magenta shades: + * french_pink: #ec6c99 + * lavender: #e6a2f3 + * + * Cyan shades: + * zomp: #39a78d + * seafoam_green: #00ff7f + */ + +/* Style the UI: */ + +/* Equivalent to Vim's Normal group. */ +.cm-s-abbott.CodeMirror { + background: #231c14 /* bistre */; + color: #d8ff84 /* pastel_chartreuse */; +} + +/* Roughly equivalent to Vim's LineNr group. */ +.cm-s-abbott .CodeMirror-gutters { + background: #231c14 /* bistre */; + border: none; +} +.cm-s-abbott .CodeMirror-linenumber { color: #fbec5d /* lemon_meringue */; } + +.cm-s-abbott .CodeMirror-guttermarker { color: #f63f05 /* cinnabar */; } + +/* Roughly equivalent to Vim's FoldColumn group. */ +.cm-s-abbott .CodeMirror-guttermarker-subtle { color: #fbb32f /* marigold */; } + +/* + * Roughly equivalent to Vim's CursorColumn group. (We use a brighter color + * since Vim's cursorcolumn option highlights a whole column, whereas + * CodeMirror's rule just highlights a thin line.) + */ +.cm-s-abbott .CodeMirror-ruler { border-color: #745d42 /* cocoa */; } + +/* Equivalent to Vim's Cursor group in insert mode. */ +.cm-s-abbott .CodeMirror-cursor { border-color: #a0ea00 /* chartreuse */; } + +/* Equivalent to Vim's Cursor group in normal mode. */ +.cm-s-abbott.cm-fat-cursor .CodeMirror-cursor, +.cm-s-abbott .cm-animate-fat-cursor { + /* + * CodeMirror doesn't allow changing the foreground color of the character + * under the cursor, so we can't use a reverse video effect for the cursor. + * Instead, make it semitransparent. + */ + background: rgba(160, 234, 0, 0.5) /* chartreuse */; +} +.cm-s-abbott.cm-fat-cursor .CodeMirror-cursors { + /* + * Boost the z-index so the fat cursor shows up on top of text and + * matchingbracket/matchingtag highlights. + */ + z-index: 3; +} + +/* Equivalent to Vim's Cursor group in replace mode. */ +.cm-s-abbott .CodeMirror-overwrite .CodeMirror-cursor { + border-bottom: 1px solid #a0ea00 /* chartreuse */; + border-left: none; + width: auto; +} + +/* Roughly equivalent to Vim's CursorIM group. */ +.cm-s-abbott .CodeMirror-secondarycursor { + border-color: #00ff7f /* seafoam_green */; +} + +/* Roughly equivalent to Vim's Visual group. */ +.cm-s-abbott .CodeMirror-selected, +.cm-s-abbott.CodeMirror-focused .CodeMirror-selected { + background: #273900 /* dark_olive */; +} +.cm-s-abbott .CodeMirror-line::selection, +.cm-s-abbott .CodeMirror-line > span::selection, +.cm-s-abbott .CodeMirror-line > span > span::selection { + background: #273900 /* dark_olive */; +} +.cm-s-abbott .CodeMirror-line::-moz-selection, +.cm-s-abbott .CodeMirror-line > span::-moz-selection, +.cm-s-abbott .CodeMirror-line > span > span::-moz-selection { + background: #273900 /* dark_olive */; +} + +/* Roughly equivalent to Vim's SpecialKey group. */ +.cm-s-abbott .cm-tab { color: #00ff7f /* seafoam_green */; } + +/* Equivalent to Vim's Search group. */ +.cm-s-abbott .cm-searching { + background: #fef3b4 /* vanilla_cream */ !important; + color: #231c14 /* bistre */ !important; +} + +/* Style syntax highlighting modes: */ + +/* Equivalent to Vim's Comment group. */ +.cm-s-abbott span.cm-comment { + color: #fbb32f /* marigold */; + font-style: italic; +} + +/* Equivalent to Vim's String group. */ +.cm-s-abbott span.cm-string, +.cm-s-abbott span.cm-string-2 { + color: #e6a2f3 /* lavender */; +} + +/* Equivalent to Vim's Constant group. */ +.cm-s-abbott span.cm-number, +.cm-s-abbott span.cm-string.cm-url { color: #f63f05 /* cinnabar */; } + +/* Roughly equivalent to Vim's SpecialKey group. */ +.cm-s-abbott span.cm-invalidchar { color: #00ff7f /* seafoam_green */; } + +/* Equivalent to Vim's Special group. */ +.cm-s-abbott span.cm-atom { color: #fef3b4 /* vanilla_cream */; } + +/* Equivalent to Vim's Delimiter group. */ +.cm-s-abbott span.cm-bracket, +.cm-s-abbott span.cm-punctuation { + color: #fef3b4 /* vanilla_cream */; +} + +/* Equivalent Vim's Operator group. */ +.cm-s-abbott span.cm-operator { font-weight: bold; } + +/* Roughly equivalent to Vim's Identifier group. */ +.cm-s-abbott span.cm-def, +.cm-s-abbott span.cm-variable, +.cm-s-abbott span.cm-variable-2, +.cm-s-abbott span.cm-variable-3 { + color: #8ccdf0 /* periwinkle_blue */; +} + +/* Roughly equivalent to Vim's Function group. */ +.cm-s-abbott span.cm-builtin, +.cm-s-abbott span.cm-property, +.cm-s-abbott span.cm-qualifier { + color: #3f91f1 /* cornflower_blue */; +} + +/* Equivalent to Vim's Type group. */ +.cm-s-abbott span.cm-type { color: #24a507 /* forest_green */; } + +/* Equivalent to Vim's Keyword group. */ +.cm-s-abbott span.cm-keyword { + color: #d80450 /* crimson */; + font-weight: bold; +} + +/* Equivalent to Vim's PreProc group. */ +.cm-s-abbott span.cm-meta { color: #ec6c99 /* french_pink */; } + +/* Equivalent to Vim's htmlTagName group (linked to Statement). */ +.cm-s-abbott span.cm-tag { + color: #d80450 /* crimson */; + font-weight: bold; +} + +/* Equivalent to Vim's htmlArg group (linked to Type). */ +.cm-s-abbott span.cm-attribute { color: #24a507 /* forest_green */; } + +/* Equivalent to Vim's htmlH1, markdownH1, etc. groups (linked to Title). */ +.cm-s-abbott span.cm-header { + color: #d80450 /* crimson */; + font-weight: bold; +} + +/* Equivalent to Vim's markdownRule group (linked to PreProc). */ +.cm-s-abbott span.cm-hr { color: #ec6c99 /* french_pink */; } + +/* Roughly equivalent to Vim's Underlined group. */ +.cm-s-abbott span.cm-link { color: #e6a2f3 /* lavender */; } + +/* Equivalent to Vim's diffRemoved group. */ +.cm-s-abbott span.cm-negative { + background: #d80450 /* crimson */; + color: #231c14 /* bistre */; +} + +/* Equivalent to Vim's diffAdded group. */ +.cm-s-abbott span.cm-positive { + background: #a0ea00 /* chartreuse */; + color: #231c14 /* bistre */; + font-weight: bold; +} + +/* Equivalent to Vim's Error group. */ +.cm-s-abbott span.cm-error { + background: #d80450 /* crimson */; + color: #231c14 /* bistre */; +} + +/* Style addons: */ + +/* Equivalent to Vim's MatchParen group. */ +.cm-s-abbott span.CodeMirror-matchingbracket { + background: #745d42 /* cocoa */ !important; + color: #231c14 /* bistre */ !important; + font-weight: bold; +} + +/* + * Roughly equivalent to Vim's Error group. (Vim doesn't seem to have a direct + * equivalent in its own matchparen plugin, but many syntax highlighting plugins + * mark mismatched brackets as Error.) + */ +.cm-s-abbott span.CodeMirror-nonmatchingbracket { + background: #d80450 /* crimson */ !important; + color: #231c14 /* bistre */ !important; +} + +.cm-s-abbott .CodeMirror-matchingtag, +.cm-s-abbott .cm-matchhighlight { + outline: 1px solid #39a78d /* zomp */; +} + +/* Equivalent to Vim's CursorLine group. */ +.cm-s-abbott .CodeMirror-activeline-background, +.cm-s-abbott .CodeMirror-activeline-gutter { + background: #3c3022 /* chocolate */; +} + +/* Equivalent to Vim's CursorLineNr group. */ +.cm-s-abbott .CodeMirror-activeline-gutter .CodeMirror-linenumber { + color: #d8ff84 /* pastel_chartreuse */; + font-weight: bold; +} + +/* Roughly equivalent to Vim's Folded group. */ +.cm-s-abbott .CodeMirror-foldmarker { + color: #f63f05 /* cinnabar */; + text-shadow: none; +} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/abcdef.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/abcdef.css new file mode 100644 index 000000000000..cf93530946b4 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/abcdef.css @@ -0,0 +1,32 @@ +.cm-s-abcdef.CodeMirror { background: #0f0f0f; color: #defdef; } +.cm-s-abcdef div.CodeMirror-selected { background: #515151; } +.cm-s-abcdef .CodeMirror-line::selection, .cm-s-abcdef .CodeMirror-line > span::selection, .cm-s-abcdef .CodeMirror-line > span > span::selection { background: rgba(56, 56, 56, 0.99); } +.cm-s-abcdef .CodeMirror-line::-moz-selection, .cm-s-abcdef .CodeMirror-line > span::-moz-selection, .cm-s-abcdef .CodeMirror-line > span > span::-moz-selection { background: rgba(56, 56, 56, 0.99); } +.cm-s-abcdef .CodeMirror-gutters { background: #555; border-right: 2px solid #314151; } +.cm-s-abcdef .CodeMirror-guttermarker { color: #222; } +.cm-s-abcdef .CodeMirror-guttermarker-subtle { color: azure; } +.cm-s-abcdef .CodeMirror-linenumber { color: #FFFFFF; } +.cm-s-abcdef .CodeMirror-cursor { border-left: 1px solid #00FF00; } + +.cm-s-abcdef span.cm-keyword { color: darkgoldenrod; font-weight: bold; } +.cm-s-abcdef span.cm-atom { color: #77F; } +.cm-s-abcdef span.cm-number { color: violet; } +.cm-s-abcdef span.cm-def { color: #fffabc; } +.cm-s-abcdef span.cm-variable { color: #abcdef; } +.cm-s-abcdef span.cm-variable-2 { color: #cacbcc; } +.cm-s-abcdef span.cm-variable-3, .cm-s-abcdef span.cm-type { color: #def; } +.cm-s-abcdef span.cm-property { color: #fedcba; } +.cm-s-abcdef span.cm-operator { color: #ff0; } +.cm-s-abcdef span.cm-comment { color: #7a7b7c; font-style: italic;} +.cm-s-abcdef span.cm-string { color: #2b4; } +.cm-s-abcdef span.cm-meta { color: #C9F; } +.cm-s-abcdef span.cm-qualifier { color: #FFF700; } +.cm-s-abcdef span.cm-builtin { color: #30aabc; } +.cm-s-abcdef span.cm-bracket { color: #8a8a8a; } +.cm-s-abcdef span.cm-tag { color: #FFDD44; } +.cm-s-abcdef span.cm-attribute { color: #DDFF00; } +.cm-s-abcdef span.cm-error { color: #FF0000; } +.cm-s-abcdef span.cm-header { color: aquamarine; font-weight: bold; } +.cm-s-abcdef span.cm-link { color: blueviolet; } + +.cm-s-abcdef .CodeMirror-activeline-background { background: #314151; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/ambiance-mobile.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/ambiance-mobile.css new file mode 100644 index 000000000000..88d332e1a79c --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/ambiance-mobile.css @@ -0,0 +1,5 @@ +.cm-s-ambiance.CodeMirror { + -webkit-box-shadow: none; + -moz-box-shadow: none; + box-shadow: none; +} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/ambiance.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/ambiance.css new file mode 100644 index 000000000000..782fca43f527 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/ambiance.css @@ -0,0 +1,74 @@ +/* ambiance theme for codemirror */ + +/* Color scheme */ + +.cm-s-ambiance .cm-header { color: blue; } +.cm-s-ambiance .cm-quote { color: #24C2C7; } + +.cm-s-ambiance .cm-keyword { color: #cda869; } +.cm-s-ambiance .cm-atom { color: #CF7EA9; } +.cm-s-ambiance .cm-number { color: #78CF8A; } +.cm-s-ambiance .cm-def { color: #aac6e3; } +.cm-s-ambiance .cm-variable { color: #ffb795; } +.cm-s-ambiance .cm-variable-2 { color: #eed1b3; } +.cm-s-ambiance .cm-variable-3, .cm-s-ambiance .cm-type { color: #faded3; } +.cm-s-ambiance .cm-property { color: #eed1b3; } +.cm-s-ambiance .cm-operator { color: #fa8d6a; } +.cm-s-ambiance .cm-comment { color: #555; font-style:italic; } +.cm-s-ambiance .cm-string { color: #8f9d6a; } +.cm-s-ambiance .cm-string-2 { color: #9d937c; } +.cm-s-ambiance .cm-meta { color: #D2A8A1; } +.cm-s-ambiance .cm-qualifier { color: yellow; } +.cm-s-ambiance .cm-builtin { color: #9999cc; } +.cm-s-ambiance .cm-bracket { color: #24C2C7; } +.cm-s-ambiance .cm-tag { color: #fee4ff; } +.cm-s-ambiance .cm-attribute { color: #9B859D; } +.cm-s-ambiance .cm-hr { color: pink; } +.cm-s-ambiance .cm-link { color: #F4C20B; } +.cm-s-ambiance .cm-special { color: #FF9D00; } +.cm-s-ambiance .cm-error { color: #AF2018; } + +.cm-s-ambiance .CodeMirror-matchingbracket { color: #0f0; } +.cm-s-ambiance .CodeMirror-nonmatchingbracket { color: #f22; } + +.cm-s-ambiance div.CodeMirror-selected { background: rgba(255, 255, 255, 0.15); } +.cm-s-ambiance.CodeMirror-focused div.CodeMirror-selected { background: rgba(255, 255, 255, 0.10); } +.cm-s-ambiance .CodeMirror-line::selection, .cm-s-ambiance .CodeMirror-line > span::selection, .cm-s-ambiance .CodeMirror-line > span > span::selection { background: rgba(255, 255, 255, 0.10); } +.cm-s-ambiance .CodeMirror-line::-moz-selection, .cm-s-ambiance .CodeMirror-line > span::-moz-selection, .cm-s-ambiance .CodeMirror-line > span > span::-moz-selection { background: rgba(255, 255, 255, 0.10); } + +/* Editor styling */ + +.cm-s-ambiance.CodeMirror { + line-height: 1.40em; + color: #E6E1DC; + background-color: #202020; + -webkit-box-shadow: inset 0 0 10px black; + -moz-box-shadow: inset 0 0 10px black; + box-shadow: inset 0 0 10px black; +} + +.cm-s-ambiance .CodeMirror-gutters { + background: #3D3D3D; + border-right: 1px solid #4D4D4D; + box-shadow: 0 10px 20px black; +} + +.cm-s-ambiance .CodeMirror-linenumber { + text-shadow: 0px 1px 1px #4d4d4d; + color: #111; + padding: 0 5px; +} + +.cm-s-ambiance .CodeMirror-guttermarker { color: #aaa; } +.cm-s-ambiance .CodeMirror-guttermarker-subtle { color: #111; } + +.cm-s-ambiance .CodeMirror-cursor { border-left: 1px solid #7991E8; } + +.cm-s-ambiance .CodeMirror-activeline-background { + background: none repeat scroll 0% 0% rgba(255, 255, 255, 0.031); +} + +.cm-s-ambiance.CodeMirror, +.cm-s-ambiance .CodeMirror-gutters { + background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAADICAQAAAAHUWYVAABFFUlEQVQYGbzBCeDVU/74/6fj9HIcx/FRHx9JCFmzMyGRURhLZIkUsoeRfUjS2FNDtr6WkMhO9sm+S8maJfu+Jcsg+/o/c+Z4z/t97/vezy3z+z8ekGlnYICG/o7gdk+wmSHZ1z4pJItqapjoKXWahm8NmV6eOTbWUOp6/6a/XIg6GQqmenJ2lDHyvCFZ2cBDbmtHA043VFhHwXxClWmeYAdLhV00Bd85go8VmaFCkbVkzlQENzfBDZ5gtN7HwF0KDrTwJ0dypSOzpaKCMwQHKTIreYIxlmhXTzTWkVm+LTynZhiSBT3RZQ7aGfjGEd3qyXQ1FDymqbKxpspERQN2MiRjNZlFFQXfCNFm9nM1zpAsoYjmtRTc5ajwuaXc5xrWskT97RaKzAGe5ARHhVUsDbjKklziiX5WROcJwSNCNI+9w1Jwv4Zb2r7lCMZ4oq5C0EdTx+2GzNuKpJ+iFf38JEWkHJn9DNF7mmBDITrWEg0VWL3pHU20tSZnuqWu+R3BtYa8XxV1HO7GyD32UkOpL/yDloINFTmvtId+nmAjxRw40VMwVKiwrKLE4bK5UOVntYwhOcSSXKrJHKPJedocpGjVz/ZMIbnYUPB10/eKCrs5apqpgVmWzBYWpmtKHecJPjaUuEgRDDaU0oZghCJ6zNMQ5ZhDYx05r5v2muQdM0EILtXUsaKiQX9WMEUotagQzFbUNN6NUPC2nm5pxEWGCjMc3GdJHjSU2kORLK/JGSrkfGEIjncU/CYUnOipoYemwj8tST9NsJmB7TUVXtbUtXATJVZXBMvYeTXJfobgJUPmGMP/yFaWonaa6BcFO3nqcIqCozSZoZoSr1g4zJOzuyGnxTEX3lUEJ7WcZgme8ddaWvWJo2AJR9DZU3CUIbhCSG6ybSwN6qtJVnCU2svDTP2ZInOw2cBTrqtQahtNZn9NcJ4l2NaSmSkkP1noZWnVwkLmdUPOwLZEwy2Z3S3R+4rIG9hcbpPXHFVWcQdZkn2FOta3cKWQnNRC5g1LsJah4GCzSVsKnCOY5OAFRTBekyyryeyilhFKva75r4Mc0aWanGEaThcy31s439KKxTzJYY5WTHPU1FtIHjQU3Oip4xlNzj/lBw23dYZVliQa7WAXf4shetcQfatI+jWRDBPmyNeW6A1P5kdDgyYJlba0BIM8BZu1JfrFwItyjcAMR3K0BWOIrtMEXyhyrlVEx3ui5dUBjmB/Q3CXW85R4mBD0s7B+4q5tKUjOlb9qqmhi5AZ6GFIC5HXtOobdYGlVdMVbNJ8toNTFcHxnoL+muBagcctjWnbNMuR00uI7nQESwg5q2qqrKWIfrNUmeQocY6HuyxJV02wj36w00yhpmUFenv4p6fUkZYqLyuinx2RGOjhCXYyJF84oiU00YMOOhhquNdfbOB7gU88pY4xJO8LVdp6/q2voeB4R04vIdhSE40xZObx1HGGJ/ja0LBthFInKaLPPFzuCaYaoj8JjPME8yoyxo6zlBqkiUZYgq00OYMswbWO5NGmq+xhipxHLRW29ARjNKXO0wRnear8XSg4XFPLKEPUS1GqvyLwiuBUoa7zpZ0l5xxFwWmWZC1H5h5FwU8eQ7K+g8UcVY6TMQreVQT/8uQ8Z+ALIXnSEa2pYZQneE9RZbSBNYXfWYJzW/h/4j4Dp1tYVcFIC5019Vyi4ThPqSFCzjGWaHQTBU8q6vrVwgxP9Lkm840imWKpcLCjYTtrKuwvsKSnrvHCXGkSMk9p6lhckfRpIeis+N2PiszT+mFLspyGleUhDwcLrZqmyeylxwjBcKHEapqkmyangyLZRVOijwOtCY5SsG5zL0OwlCJ4y5KznF3EUNDDrinwiyLZRzOXtlBbK5ITHFGLp8Q0R6ab6mS7enI2cFrxOyHvOCFaT1HThS1krjCwqWeurCkk+willhCC+RSZnRXBiZaC5RXRIZYKp2lyfrHwiKPKR0JDzrdU2EFgpidawlFDR6FgXUMNa+g1FY3bUQh2cLCwosRdnuQTS/S+JVrGLeWIvtQUvONJxlqSQYYKpwoN2kaocLjdVsis4Mk80ESF2YpSkzwldjHkjFCUutI/r+EHDU8oCs6yzL3PhWiEooZdFMkymlas4AcI3KmoMMNSQ3tHzjGWCrcJJdYyZC7QFGwjRL9p+MrRkAGWzIaWCn9W0F3TsK01c2ZvQw0byvxuQU0r1lM0qJO7wW0kRIMdDTtXEdzi4VIh+EoIHm0mWtAtpCixlabgn83fKTI7anJe9ST7WIK1DMGpQmYeA58ImV6ezOGOzK2Kgq01pd60cKWiUi9Lievb/0vIDPHQ05Kzt4ddPckQBQtoaurjyHnek/nKzpQLrVgKPjIkh2v4uyezpv+Xoo7fPFXaGFp1vaLKxQ4uUpQQS5VuQs7BCq4xRJv7fwpVvvFEB3j+620haOuocqMhWd6TTPAEx+mdFNGHdranFe95WrWmIvlY4F1Dle2ECgc6cto7SryuqGGGha0tFQ5V53migUKmg6XKAo4qS3mik+0OZpAhOLeZKicacgaYcyx5hypYQE02ZA4xi/pNhOQxR4klNKyqacj+mpxnLTnnGSo85++3ZCZq6lrZkXlGEX3o+C9FieccJbZWVFjC0Yo1FZnJhoYMFoI1hEZ9r6hwg75HwzBNhbZCdJEfJwTPGzJvaKImw1yYX1HDAmpXR+ZJQ/SmgqMNVQb5vgamGwLtt7VwvP7Qk1xpiM5x5Cyv93E06MZmgs0Nya2azIKOYKCGBQQW97RmhKNKF02JZqHEJ4o58qp7X5EcZmc56trXEqzjCBZ1MFGR87Ql2tSTs6CGxS05PTzRQorkbw7aKoKXFDXsYW42VJih/q+FP2BdTzDTwVqOYB13liM50vG7wy28qagyuIXMeQI/Oqq8bcn5wJI50xH00CRntyfpL1T4hydYpoXgNiFzoIUTDZnLNRzh4TBHwbYGDvZkxmlyJloyr6tRihpeUG94GnKtIznREF0tzJG/OOr73JBcrSh1k6WuTprgLU+mnSGnv6Zge0NNz+kTDdH8nuAuTdJDCNb21LCiIuqlYbqGzT3RAoZofQfjFazkqeNWdYaGvYTM001EW2oKPvVk1ldUGSgUtHFwjKM1h9jnFcmy5lChoLNaQMGGDsYbKixlaMBmmsx1QjCfflwTfO/gckW0ruZ3jugKR3R5W9hGUWqCgxuFgsuaCHorotGKzGaeZB9DMsaTnKCpMtwTvOzhYk0rdrArKCqcaWmVk1+F372ur1YkKxgatI8Qfe1gIX9wE9FgS8ESmuABIXnRUbCapcKe+nO7slClSZFzpV/LkLncEb1qiO42fS3R855Su2mCLh62t1SYZZYVmKwIHjREF2uihTzB20JOkz7dkxzYQnK0UOU494wh+VWRc6Un2kpTaVgLDFEkJ/uhzRcI0YKGgpGWOlocBU/a4fKoJ/pEaNV6jip3+Es9VXY078rGnmAdf7t9ylPXS34RBSuYPs1UecZTU78WanhBCHpZ5sAoTz0LGZKjPf9TRypqWEiTvOFglL1fCEY3wY/++rbk7C8bWebA6p6om6PgOL2kp44TFJlVNBXae2rqqdZztOJpT87GQsE9jqCPIe9VReZuQ/CIgacsyZdCpIScSYqcZk8r+nsyCzhyfhOqHGOIvrLknC8wTpFcaYiGC/RU1NRbUeUpocQOnkRpGOrIOcNRx+1uA0UrzhSSt+VyS3SJpnFWkzNDqOFGIWcfR86DnmARTQ1HKIL33ExPiemeOhYSSjzlSUZZuE4TveoJLnBUOFof6KiysCbnAEcZgcUNTDOwkqWu3RWtmGpZwlHhJENdZ3miGz0lJlsKnjbwqSHQjpxnFDlTLLwqJPMZMjd7KrzkSG7VsxXBZE+F8YZkb01Oe00yyRK9psh5SYh29ySPKBo2ylNht7ZkZnsKenjKNJu9PNEyZpaCHv4Kt6RQsLvAVp7M9kIimmCUwGeWqLMmGuIotYMmWNpSahkhZw9FqZsVnKJhsjAHvtHMsTM9fCI06Dx/u3vfUXCqfsKRc4oFY2jMsoo/7DJDwZ1CsIKnJu+J9ldkpmiCxQx1rWjI+T9FwcWWzOuaYH0Hj7klNRVWEQpmaqosakiGNTFHdjS/qnUdmf0NJW5xsL0HhimCCZZSRzmSPTXJQ4aaztAwtZnoabebJ+htCaZ7Cm535ByoqXKbX1WRc4Eh2MkRXWzImVc96Cj4VdOKVxR84VdQsIUM8Psoou2byVHyZFuq7O8otbSQ2UAoeEWTudATLGSpZzVLlXVkPU2Jc+27lsw2jmg5T5VhbeE3BT083K9WsTTkFU/Osi0rC5lRlpwRHUiesNS0sOvmqGML1aRbPAxTJD9ZKtxuob+hhl8cwYGWpJ8nub7t5p6coYbMovZ1BTdaKn1jYD6h4GFDNFyT/Kqe1XCXphXHOKLZmuRSRdBPEfVUXQzJm5YGPGGJdvAEr7hHNdGZnuBvrpciGmopOLf5N0uVMy0FfYToJk90uUCbJupaVpO53UJXR2bVpoU00V2KOo4zMFrBd0Jtz2pa0clT5Q5L8IpQ177mWQejPMEJhuQjS10ref6HHjdEhy1P1EYR7GtO0uSsKJQYLiTnG1rVScj5lyazpqWGl5uBbRWl7m6ixGOOnEsMJR7z8J0n6KMnCdxhiNYQCoZ6CmYLnO8omC3MkW3bktlPmEt/VQQHejL3+dOE5FlPdK/Mq8hZxxJtLyRrepLThYKbLZxkSb5W52vYxNOaOxUF0yxMUPwBTYqCzy01XayYK0sJyWBLqX0MwU5CzoymRzV0EjjeUeLgDpTo6ij42ZAzvD01dHUUTPLU96MdLbBME8nFBn7zJCMtJcZokn8YoqU0FS5WFKyniHobguMcmW8N0XkWZjkyN3hqOMtS08r+/xTBwpZSZ3qiVRX8SzMHHjfUNFjgHEPmY9PL3ykEzxkSre/1ZD6z/NuznuB0RcE1TWTm9zRgfUWVJiG6yrzgmWPXC8EAR4Wxhlad0ZbgQyEz3pG5RVEwwDJH2mgKpjcTiCOzn1lfUWANFbZ2BA8balnEweJC9J0iuaeZoI+ippFCztEKVvckR2iice1JvhVytrQwUAZpgsubCPaU7xUe9vWnaOpaSBEspalykhC9bUlOMpT42ZHca6hyrqKmw/wMR8H5ZmdFoBVJb03O4UL0tSNnvIeRmkrLWqrs78gcrEn2tpcboh0UPOW3UUR9PMk4T4nnNKWmCjlrefhCwxRNztfmIQVdDElvS4m1/WuOujoZCs5XVOjtKPGokJzsYCtFYoWonSPT21DheU/wWhM19FcElwqNGOsp9Q8N/cwXaiND1MmeL1Q5XROtYYgGeFq1aTMsoMmcrKjQrOFQTQ1fmBYhmW6o8Jkjc7iDJRTBIo5kgJD5yMEYA3srCg7VFKwiVJkmRCc5ohGOKhsYMn/XBLdo5taZjlb9YAlGWRimqbCsoY7HFAXLa5I1HPRxMMsQDHFkWtRNniqT9UEeNjcE7RUlrCJ4R2CSJuqlKHWvJXjAUNcITYkenuBRB84TbeepcqTj3zZyFJzgYQdHnqfgI0ddUwS6GqWpsKWhjq9cV0vBAEMN2znq+EBfIWT+pClYw5xsTlJU6GeIBsjGmmANTzJZiIYpgrM0Oa8ZMjd7NP87jxhqGOhJlnQtjuQpB+8aEE00wZFznSJPyHxgH3HkPOsJFvYk8zqCHzTs1BYOa4J3PFU+UVRZxlHDM4YavlNUuMoRveiZA2d7grMNc2g+RbSCEKzmgYsUmWmazFJyoiOZ4KnyhKOGRzWJa0+moyV4TVHDzn51Awtqaphfk/lRQ08FX1iiqxTB/kLwd0VynKfEvI6cd4XMV5bMhZ7gZUWVzYQ6Nm2BYzxJbw3bGthEUUMfgbGeorae6DxHtJoZ6alhZ0+ytiVoK1R4z5PTrOECT/SugseEOlb1MMNR4VRNcJy+V1Hg9ONClSZFZjdHlc6W6FBLdJja2MC5hhpu0DBYEY1TFGwiFAxRRCsYkiM9JRb0JNMVkW6CZYT/2EiTGWmo8k+h4FhDNE7BvppoTSFnmCV5xZKzvcCdDo7VVPnIU+I+Rc68juApC90MwcFCsJ5hDqxgScYKreruyQwTqrzoqDCmhWi4IbhB0Yrt3RGa6GfDv52rKXWhh28dyZaWUvcZeMTBaZoSGyiCtRU5J8iviioHaErs7Jkj61syVzTTgOcUOQ8buFBTYWdL5g3T4qlpe0+wvD63heAXRfCCIed9RbCsp2CiI7raUOYOTU13N8PNHvpaGvayo4a3LLT1lDrVEPT2zLUlheB1R+ZTRfKWJ+dcocLJfi11vyJ51lLqJ0WD7tRwryezjiV5W28uJO9qykzX8JDe2lHl/9oyBwa2UMfOngpXCixvKdXTk3wrsKmiVYdZIqsoWEERjbcUNDuiaQomGoIbFdEHmsyWnuR+IeriKDVLnlawlyNHKwKlSU631PKep8J4Q+ayjkSLKYLhalNHlYvttb6fHm0p6OApsZ4l2VfdqZkjuysy6ysKLlckf1KUutCTs39bmCgEyyoasIWlVaMF7mgmWtBT8Kol5xpH9IGllo8cJdopcvZ2sImlDmMIbtDk3KIpeNiS08lQw11NFPTwVFlPP6pJ2gvRfI7gQUfmNAtf6Gs0wQxDsKGlVBdF8rCa3jzdwMaGHOsItrZk7hAyOzpK9VS06j5F49b0VNGOOfKs3lDToMsMBe9ZWtHFEgxTJLs7qrygKZjUnmCYoeAqeU6jqWuLJup4WghOdvCYJnrSkSzoyRkm5M2StQwVltPkfCAk58tET/CSg+8MUecmotMEnhBKfWBIZsg2ihruMJQaoIm+tkTLKEqspMh00w95gvFCQRtDwTT1gVDDSEVdlwqZfxoQRbK0g+tbiBZxzKlpnpypejdDwTaeOvorMk/IJE10h9CqRe28hhLbe0pMsdSwv4ZbhKivo2BjDWfL8UKJgeavwlwb5KlwhyE4u4XkGE2ytZCznKLCDZZq42VzT8HLCrpruFbIfOIINmh/qCdZ1ZBc65kLHR1Bkyf5zn6pN3SvGKIlFNGplhrO9QSXanLOMQTLCa0YJCRrCZm/CZmrLTm7WzCK4GJDiWUdFeYx1LCFg3NMd0XmCuF3Y5rITLDUsYS9zoHVzwnJoYpSTQoObyEzr4cFBNqYTopoaU/wkyLZ2lPhX/5Y95ulxGTV7KjhWrOZgl8MyUUafjYraNjNU1N3IWcjT5WzWqjwtoarHSUObGYO3GCJZpsBlnJGPd6ZYLyl1GdCA2625IwwJDP8GUKymbzuyPlZlvTUsaUh5zFDhRWFzPKKZLAlWdcQbObgF9tOqOsmB1dqcqYJmWstFbZRRI9poolmqiLnU0POvxScpah2iSL5UJNzgScY5+AuIbpO0YD3NCW+dLMszFSdFCWGqG6eVq2uYVNDdICGD6W7EPRWZEY5gpsE9rUkS3mijzzJnm6UpUFXG1hCUeVoS5WfNcFpblELL2qqrCvMvRfd45oalvKU2tiQ6ePJOVMRXase9iTtLJztPxJKLWpo2CRDcJwn2sWSLKIO1WQWNTCvpVUvOZhgSC40JD0dOctaSqzkCRbXsKlb11Oip6PCJ0IwSJM31j3akRxlP7Rwn6aGaUL0qiLnJkvB3xWZ2+Q1TfCwpQH3G0o92UzmX4o/oJNQMMSQc547wVHhdk+VCw01DFYEnTxzZKAm74QmeNNR1w6WzEhNK15VJzuCdxQ53dRUDws5KvwgBMOEgpcVNe0hZI6RXT1Jd0cyj5nsaEAHgVmGaJIlWdsc5Ui2ElrRR6jrRAttNMEAIWrTDFubkZaok7/AkzfIwfuWVq0jHzuCK4QabtLUMVPB3kJ0oyHTSVFlqMALilJf2Rf8k5aaHtMfayocLBS8L89oKoxpJvnAkDPa0qp5DAUTHKWmCcnthlou8iCKaFFLHWcINd1nyIwXqrSxMNmSs6KmoL2QrKuWtlQ5V0120xQ5vRyZS1rgFkWwhiOwiuQbR0OOVhQM9iS3tiXp4RawRPMp5tDletOOBL95MpM01dZTBM9pkn5qF010rIeHFcFZhmSGpYpTsI6nwhqe5C9ynhlpp5ophuRb6WcJFldkVnVEwwxVfrVkvnWUuNLCg5bgboFHPDlDPDmnK7hUrWiIbjadDclujlZcaokOFup4Ri1kacV6jmrrK1hN9bGwpKEBQ4Q6DvIUXOmo6U5LqQM6EPyiKNjVkPnJkDPNEaxhiFay5ExW1NXVUGqcpYYdPcGiCq7z/TSlbhL4pplWXKd7NZO5QQFrefhRQW/NHOsqcIglc4UhWklR8K0QzbAw08CBDnpbgqXdeD/QUsM4RZXDFBW6WJKe/mFPdH0LtBgiq57wFLzlyQzz82qYx5D5WJP5yVJDW01BfyHnS6HKO/reZqId1WGa4Hkh2kWodJ8i6KoIPlAj2hPt76CzXsVR6koPRzWTfKqIentatYpQw2me4AA3y1Kind3SwoOKZDcFXTwl9tWU6mfgRk9d71sKtlNwrjnYw5tC5n5LdKiGry3JKNlHEd3oaMCFHrazBPMp/uNJ+V7IudcSbeOIdjUEdwl0VHCOZo5t6YluEuaC9mQeMgSfOyKnYGFHcIeQ84yQWbuJYJpZw5CzglDH7gKnWqqM9ZTaXcN0TeYhR84eQtJT76JJ1lREe7WnnvsMmRc9FQ7SBBM9mV3lCUdmHk/S2RAMt0QjFNFqQpWjDPQ01DXWUdDBkXziKPjGEP3VP+zIWU2t7im41FOloyWzn/L6dkUy3VLDaZ6appgDLHPjJEsyvJngWEPUyVBiAaHCTEXwrLvSEbV1e1gKJniicWorC1MUrVjB3uDhJE/wgSOzk1DXpk0k73qCM8xw2UvD5kJmDUfOomqMpWCkJRlvKXGmoeBm18USjVIk04SClxTB6YrgLAPLWYK9HLUt5cmc0vYES8GnTeRc6skZbQkWdxRsIcyBRzx1DbTk9FbU0caTPOgJHhJKnOGIVhQqvKmo0llRw9sabrZkDtdg3PqaKi9oatjY8B+G371paMg6+mZFNNtQ04mWBq3rYLOmtWWQp8KJnpy9DdFensyjdqZ+yY40VJlH8wcdLzC8PZnvHMFUTZUrDTkLyQaGus5X5LzpYAf3i+e/ZlhqGqWhh6Ou6xTR9Z6oi5AZZtp7Mj2EEm8oSpxiYZCHU/1fbGdNNNRRoZMhmilEb2gqHOEJDtXkHK/JnG6IrvbPCwV3NhONVdS1thBMs1T4QOBcTWa2IzhMk2nW5Kyn9tXUtpv9RsG2msxk+ZsQzRQacJncpgke0+T8y5Fzj8BiGo7XlJjaTIlpQs7KFjpqGnKuoyEPeIKnFMkZHvopgh81ySxNFWvJWcKRs70j2FOT012IllEEO1n4pD1513Yg2ssQPOThOkvyrqHUdEXOSEsihmBbTbKX1kLBPWqWkLOqJbjB3GBIZmoa8qWl4CG/iZ7oiA72ZL7TJNeZUY7kFQftDcHHluBzRbCegzMtrRjVQpX2lgoPKKLJAkcbMl01XK2p7yhL8pCBbQ3BN2avJgKvttcrWDK3CiUOVxQ8ZP+pqXKyIxnmBymCg5vJjNfkPK4+c8cIfK8ocVt7kmfd/I5SR1hKvCzUtb+lhgc00ZaO6CyhIQP1Uv4yIZjload72PXX0OIJvnFU+0Zf6MhsJwTfW0r0UwQfW4LNLZl5HK261JCZ4qnBaAreVAS3WrjV0LBnNDUNNDToCEeFfwgcb4gOEqLRhirWkexrCEYKVV711DLYEE1XBEsp5tpTGjorkomKYF9FDXv7fR3BGwbettSxnyL53MBPjsxDZjMh+VUW9NRxq1DhVk+FSxQcaGjV9Pawv6eGByw5qzoy7xk4RsOShqjJwWKe/1pEEfzkobeD/dQJmpqedcyBTy2sr4nGNRH0c0SPWTLrqAc0OQcb/gemKgqucQT7ySWKCn2EUotoCvpZct7RO2sy/QW0IWcXd7pQRQyZVwT2USRO87uhjioTLKV2brpMUcMQRbKH/N2T+UlTpaMls6cmc6CCNy3JdYYSUzzJQ4oSD3oKLncULOiJvjBEC2oqnCJkJluCYy2ZQ5so9YYlZ1VLlQU1mXEW1jZERwj/MUSRc24TdexlqLKfQBtDTScJUV8FszXBEY5ktpD5Ur9hYB4Nb1iikw3JoYpkKX+RodRKFt53MMuRnKSpY31PwYaGaILh3wxJGz9TkTPEETxoCWZrgvOlmyMzxFEwVJE5xZKzvyJ4WxEc16Gd4Xe3Weq4XH2jKRikqOkGQ87hQnC7wBmGYLAnesX3M+S87eFATauuN+Qcrh7xIxXJbUIdMw3JGE3ylCWzrieaqCn4zhGM19TQ3z1oH1AX+pWEqIc7wNGAkULBo/ZxRaV9NNyh4Br3rCHZzbzmSfawBL0dNRwpW1kK9mxPXR9povcdrGSZK9c2k0xwFGzjuniCtRSZCZ6ccZ7gaktmgAOtKbG/JnOkJrjcQTdFMsxRQ2cLY3WTIrlCw1eWKn8R6pvt4GFDso3QoL4a3nLk3G6JrtME3dSenpx7PNFTmga0EaJTLQ061sEeQoWXhSo9LTXsaSjoJQRXeZLtDclbCrYzfzHHeaKjHCVOUkQHO3JeEepr56mhiyaYYKjjNU+Fed1wS5VlhWSqI/hYUdDOkaxiKehoyOnrCV5yBHtbWFqTHCCwtpDcYolesVR5yUzTZBb3RNMd0d6WP+SvhuBmRcGxnuQzT95IC285cr41cLGQ6aJJhmi4TMGempxeimBRQw1tFKV+8jd6KuzoSTqqDxzRtpZkurvKEHxlqXKRIjjfUNNXQsNOsRScoWFLT+YeRZVD3GRN0MdQcKqQjHDMrdGGVu3iYJpQx3WGUvfbmxwFfR20WBq0oYY7LMFhhgYtr8jpaEnaOzjawWWaTP8mMr0t/EPDPoqcnxTBI5o58L7uoWnMrpoqPwgVrlAUWE+V+TQl9rawoyP6QGAlQw2TPRX+YSkxyBC8Z6jhHkXBgQL7WII3DVFnRfCrBfxewv9D6xsyjys4VkhWb9pUU627JllV0YDNHMku/ldNMMXDEo4aFnAkk4U6frNEU4XgZUPmEKHUl44KrzmYamjAbh0JFvGnaTLPu1s9jPCwjFpYiN7z1DTOk/nc07CfDFzmCf7i+bfNHXhDtLeBXzTBT5rkMvWOIxpl4EMh2LGJBu2syDnAEx2naEhHDWMMzPZEhygyS1mS5RTJr5ZkoKbEUoYqr2kqdDUE8ztK7OaIntJkFrIECwv8LJTaVx5XJE86go8dFeZ3FN3rjabCAYpoYEeC9zzJVULBbmZhDyd7ko09ydpNZ3nm2Kee4FPPXHnYEF1nqOFEC08LUVcDvYXkJHW8gTaKCk9YGOeIJhqiE4ToPEepdp7IWFjdwnWaufGMwJJCMtUTTBBK9BGCOy2tGGrJTHIwyEOzp6aPzNMOtlZkDvcEWpP5SVNhfkvDxhmSazTJXYrM9U1E0xwFVwqZQwzJxw6+kGGGUj2FglGGmnb1/G51udRSMNlTw6GGnCcUwVcOpmsqTHa06o72sw1RL02p9z0VbnMLOaIX3QKaYKSCFQzBKEUNHTSc48k53RH9wxGMtpQa5KjjW0W0n6XCCCG4yxNNdhQ4R4l1Ff+2sSd6UFHiIEOyqqFgT01mEUMD+joy75jPhOA+oVVLm309FR4yVOlp4RhLiScNmSmaYF5Pw0STrOIoWMSR2UkRXOMp+M4SHW8o8Zoi6OZgjKOaFar8zZDzkWzvKOjkKBjmCXby8JahhjXULY4KlzgKLvAwxVGhvyd4zxB1d9T0piazmKLCVZY5sKiD0y2ZSYrkUEPUbIk+dlQ4SJHTR50k1DPaUWIdTZW9NJwnJMOECgd7ou/MnppMJ02O1VT4Wsh85MnZzcFTngpXGKo84qmwgKbCL/orR/SzJ2crA+t6Mp94KvxJUeIbT3CQu1uIdlQEOzlKfS3UMcrTiFmOuroocrZrT2AcmamOKg8YomeEKm/rlT2sociMaybaUlFhuqHCM2qIJ+rg4EcDFymiDSxzaHdPcpE62pD5kyM5SBMoA1PaUtfIthS85ig1VPiPPYXgYEMNk4Qq7TXBgo7oT57gPUdwgCHzhIVFPFU6OYJzHAX9m5oNrVjeE61miDrqQ4VSa1oiURTsKHC0IfjNwU2WzK6eqK8jWln4g15TVBnqmDteCJ501PGAocJhhqjZdtBEB6lnhLreFJKxmlKbeGrqLiSThVIbCdGzloasa6lpMQXHCME2boLpJgT7yWaemu6wBONbqGNVRS0PKIL7LckbjmQtR7K8I5qtqel+T/ChJTNIKLjdUMNIRyvOEko9YYl2cwQveBikCNawJKcLBbc7+JM92mysNvd/Fqp8a0k6CNEe7cnZrxlW0wQXaXjaktnRwNOGZKYiONwS7a1JVheq3WgJHlQUGKHKmp4KAxXR/ULURcNgoa4zhKSLpZR3kxRRb0NmD0OFn+UCS7CzI1nbP6+o4x47QZE5xRCt3ZagnYcvmpYQktXdk5YKXTzBC57kKEe0VVuiSYqapssMS3C9p2CKkHOg8B8Pa8p5atrIw3qezIWanMGa5HRDNF6RM9wcacl0N+Q8Z8hsIkSnaIIdHRUOEebAPy1zbCkhM062FCJtif7PU+UtoVXzWKqM1PxXO8cfdruhFQ/a6x3JKYagvVDhQEtNiyiiSQ7OsuRsZUku0CRNDs4Sog6KKjsZgk2bYJqijgsEenoKeniinRXBn/U3lgpPdyDZynQx8IiioMnCep5Ky8mjGs6Wty0l1hUQTcNWswS3WRp2kCNZwJG8omG8JphPUaFbC8lEfabwP7VtM9yoaNCAjpR41VNhrD9LkbN722v0CoZMByFzhaW+MyzRYEWFDQwN2M4/JiT76PuljT3VU/A36eaIThb+R9oZGOAJ9tewkgGvqOMNRWYjT/Cwu99Q8LqDE4TgbLWxJ1jaDDAERsFOFrobgjUsBScaguXU8kKm2RL19tRypSHnHNlHiIZqgufs4opgQdVdwxBNNFBR6kVFqb8ogimOzB6a6HTzrlDHEpYaxjiiA4TMQobkDg2vejjfwJGWmnbVFAw3H3hq2NyQfG7hz4aC+w3BbwbesG0swYayvpAs6++Ri1Vfzx93mFChvyN5xVHTS+0p9aqCAxyZ6ZacZyw5+7uuQkFPR9DDk9NOiE7X1PCYJVjVUqq7JlrHwWALF5nfHNGjApdpqgzx5OwilDhCiDYTgnc9waGW4BdLNNUQvOtpzDOWHDH8D7TR/A/85KljEQu3NREc4Pl/6B1Hhc8Umb5CsKMmGC9EPcxoT2amwHNCmeOEnOPbklnMkbOgIvO5UMOpQrS9UGVdt6iH/fURjhI/WOpaW9OKLYRod6HCUEdOX000wpDZQ6hwg6LgZfOqo1RfT/CrJzjekXOGhpc1VW71ZLbXyyp+93ILbC1kPtIEYx0FIx1VDrLoVzXRKRYWk809yYlC9ImcrinxtabKnzRJk3lAU1OLEN1j2zrYzr2myHRXJFf4h4QKT1qSTzTB5+ZNTzTRkAxX8FcLV2uS8eoQQ2aAkFzvCM72sJIcJET3WPjRk5wi32uSS9rfZajpWEvj9hW42F4o5NytSXYy8IKHay10VYdrcl4SkqscrXpMwyGOgtkajheSxdQqmpxP1L3t4R5PqasFnrQEjytq6qgp9Y09Qx9o4S1FzhUCn1kyHSzBWLemoSGvOqLNhZyBjmCaAUYpMgt4Ck7wBBMMwWKWgjsUwTaGVsxWC1mYoKiyqqeGKYqonSIRQ3KIkHO0pmAxTdBHkbOvfllfr+AA+7gnc50huVKYK393FOyg7rbPO/izI7hE4CnHHHnJ0ogNPRUGeUpsrZZTBJcrovUcJe51BPsr6GkJdhCCsZ6aTtMEb2pqWkqeVtDXE/QVggsU/Nl86d9RMF3DxvZTA58agu810RWawCiSzzXBeU3MMW9oyJUedvNEvQyNu1f10BSMddR1vaLCYpYa/mGocLSiYDcLbQz8aMn5iyF4xBNMs1P0QEOV7o5gaWGuzSeLue4tt3ro7y4Tgm4G/mopdZgl6q0o6KzJWE3mMksNr3r+a6CbT8g5wZNzT9O7fi/zpaOmnz3BRoqos+tv9zMbdpxsqDBOEewtJLt7cg5wtKKbvldpSzRRCD43VFheCI7yZLppggMVBS/KMAdHODJvOwq2NQSbKKKPLdFWQs7Fqo+mpl01JXYRgq8dnGLhTiFzqmWsUMdpllZdbKlyvSdYxhI9YghOtxR8LgSLWHK62mGGVoxzBE8LNWzqH9CUesQzFy5RQzTc56mhi6fgXEWwpKfE5Z7M05ZgZUPmo6auiv8YKzDYwWBLMErIbKHJvOwIrvEdhOBcQ9JdU1NHQ7CXn2XIDFBKU2WAgcX9UAUzDXWd5alwuyJ41Z9rjKLCL4aCp4WarhPm2rH+SaHUYE001JDZ2ZAzXPjdMpZWvC9wmqIB2lLhQ01D5jO06hghWMndbM7yRJMsoCj1vYbnFQVrW9jak3OlEJ3s/96+p33dEPRV5GxiqaGjIthUU6FFEZyqCa5qJrpBdzSw95IUnOPIrCUUjRZQFrbw5PR0R1qiYx3cb6nrWUMrBmmiBQxVHtTew5ICP/ip6g4hed/Akob/32wvBHsIOX83cI8hGeNeNPCIkPmXe8fPKx84OMSRM1MTdXSwjCZ4S30jVGhvqTRak/OVhgGazHuOCud5onEO1lJr6ecVyaOK6H7zqlBlIaHE0oroCgfvGJIdPcmfLNGLjpz7hZwZQpUbFME0A1cIJa7VNORkgfsMBatbKgwwJM9bSvQXeNOvbIjelg6WWvo5kvbKaJJNHexkKNHL9xRyFlH8Ti2riB5wVPhUk7nGkJnoCe428LR/wRGdYIlmWebCyxou1rCk4g/ShugBDX0V0ZQWkh0dOVsagkM0yV6OoLd5ye+pRlsCr0n+KiQrGuq5yJDzrTAXHtLUMduTDBVKrSm3eHL+6ijxhFDX9Z5gVU/wliHYTMiMFpKLNMEywu80wd3meoFmt6VbRMPenhrOc6DVe4pgXU8DnnHakLOIIrlF4FZPIw6R+zxBP0dyq6OOZ4Q5sLKCcz084ok+VsMMyQhNZmmBgX5xIXOEJTmi7VsGTvMTNdHHhpzdbE8Du2oKxgvBqQKdDDnTFOylCFaxR1syz2iqrOI/FEpNc3C6f11/7+ASS6l2inq2ciTrCCzgyemrCL5SVPjQkdPZUmGy2c9Sw9FtR1sS30RmsKPCS4rkIC/2U0MduwucYolGaPjKEyhzmiPYXagyWbYz8LWBDdzRimAXzxx4z8K9hpzlhLq+NiQ97HuKorMUfK/OVvC2JfiHUPCQI/q7J2gjK+tTDNxkCc4TMssqCs4TGtLVwQihyoAWgj9bosU80XGW6Ac9TJGziaUh5+hnFcHOnlaM1iRn29NaqGENTTTSUHCH2tWTeV0osUhH6psuVLjRUmGWhm6OZEshGeNowABHcJ2Bpy2ZszRcKkRXd2QuKVEeXnbfaEq825FguqfgfE2whlChSRMdron+LATTPQ2Z369t4B9C5gs/ylzv+CMmepIDPclFQl13W0rspPd1JOcbghGOEutqCv5qacURQl3dDKyvyJlqKXGPgcM9FfawJAMVmdcspcYKOZc4GjDYkFlK05olNMHyHn4zFNykyOxt99RkHlfwmiHo60l2EKI+mhreEKp080Tbug08BVPcgoqC5zWt+NLDTZ7oNSF51N1qie7Va3uCCwyZbkINf/NED6jzOsBdZjFN8oqG3wxVunqCSYYKf3EdhJyf9YWGf7tRU2oH3VHgPr1fe5J9hOgHd7xQ0y7qBwXr23aGErP0cm64JVjZwsOGqL+mhNgZmhJLW2oY4UhedsyBgzrCKrq7BmcpNVhR6jBPq64Vgi+kn6XE68pp8J5/+0wRHGOpsKenQn9DZntPzjRLZpDAdD2fnSgkG9tmIXnUwQ6WVighs7Yi2MxQ0N3CqYaCXkJ0oyOztMDJjmSSpcpvlrk0RMMOjmArQ04PRV1DO1FwhCVaUVPpKUM03JK5SxPsIWRu8/CGHi8UHChiqGFDTbSRJWeYUDDcH6vJWUxR4k1FXbMUwV6e4AJFXS8oMqsZKqzvYQ9DDQdZckY4aGsIhtlubbd2r3j4QBMoTamdPZk7O/Bf62lacZwneNjQoGcdVU7zJOd7ghsUHOkosagic6cnWc8+4gg285R6zZP5s1/LUbCKIznTwK36PkdwlOrl4U1LwfdCCa+IrvFkmgw1PCAUXKWo0sURXWcI2muKJlgyFzhynCY4RBOsqCjoI1R5zREco0n2Vt09BQtYSizgKNHfUmUrQ5UOCh51BFcLmY7umhYqXKQomOop8bUnWNNQcIiBcYaC6xzMNOS8JQQfeqKBmmglB+97ok/lfk3ygaHSyZaCRTzRxQo6GzLfa2jWBPepw+UmT7SQEJyiyRkhBLMVOfcoMjcK0eZChfUNzFAUzCsEN5vP/X1uP/n/aoMX+K+nw/Hjr/9xOo7j7Pju61tLcgvJpTWXNbfN5jLpi6VfCOviTktKlFusQixdEKWmEBUKNaIpjZRSSOXSgzaaKLdabrm1/9nZ+/f+vd/vz/v9+Xy+zZ7PRorYoZqyLrCwQdEAixxVOEXNNnjX2nUSRlkqGmWowk8lxR50JPy9Bo6qJXaXwNvREBvnThPEPrewryLhcAnj5WE15Fqi8W7R1sAuEu86S4ENikItFN4xkv9Af4nXSnUVcLiA9xzesFpivRRVeFKtsMRaKBhuSbjOELnAUtlSQUpXgdfB4Z1oSbnFEetbQ0IrAe+Y+pqnDcEJFj6S8LDZzZHwY4e3XONNlARraomNEt2bkvGsosA3ioyHm+6jCMbI59wqt4eeara28IzEmyPgoRaUOEDhTVdEJhmCoTWfC0p8aNkCp0oYqih2iqGi4yXeMkOsn4LdLLnmKfh/YogjNsPebeFGR4m9BJHLzB61XQ3BtpISfS2FugsK9FAtLWX1dCRcrCnUp44CNzuCowUZmxSRgYaE6Za0W2u/E7CVXCiI/UOR8aAm1+OSyE3mOUcwyc1zBBeoX1kiKy0Zfxck1Gsyulti11i83QTBF5Kg3pDQThFMVHiPSlK+0cSedng/VaS8bOZbtsBcTcZAR8JP5KeqQ1OYKAi20njdNNRpgnsU//K+JnaXJaGTomr7aYIphoRn9aeShJWKEq9LcozSF7QleEfDI5LYm5bgVkFkRwVDBCVu0DDIkGupo8TZBq+/pMQURYErJQmPKGKjNDkWOLx7Jd5QizdUweIaKrlP7SwJDhZvONjLkOsBBX9UpGxnydhXkfBLQ8IxgojQbLFnJf81JytSljclYYyEFyx0kVBvKWOFJmONpshGAcsduQY5giVNCV51eOdJYo/pLhbvM0uDHSevNKRcrKZIqnCtJeEsO95RoqcgGK4ocZcho1tTYtcZvH41pNQ7vA0WrhIfOSraIIntIAi+NXWCErdbkvrWwjRLrt0NKUdL6KSOscTOdMSOUtBHwL6OLA0vNSdynaWQEnCpIvKaIrJJEbvHkmuNhn6OjM8VkSGSqn1uYJCGHnq9I3aLhNME3t6GjIkO7xrNFumpyTNX/NrwX7CrIRiqqWijI9JO4d1iieykyfiposQIQ8YjjsjlBh6oHWbwRjgYJQn2NgSnNycmJAk3NiXhx44Sxykihxm8ybUwT1OVKySc7vi3OXVkdBJ4AyXBeksDXG0IhgtYY0lY5ahCD0ehborIk5aUWRJviMA7Xt5kyRjonrXENkm8yYqgs8VzgrJmClK20uMM3jRJ0FiQICQF9hdETlLQWRIb5ki6WDfWRPobvO6a4GP5mcOrNzDFELtTkONLh9dXE8xypEg7z8A9jkhrQ6Fhjlg/QVktJXxt4WXzT/03Q8IaQWSqIuEvloQ2mqC9Jfi7wRul4RX3pSPlzpoVlmCtI2jvKHCFhjcM3sN6lqF6HxnKelLjXWbwrpR4xzuCrTUZx2qq9oAh8p6ixCUGr78g8oyjRAtB5CZFwi80VerVpI0h+IeBxa6Zg6kWvpDHaioYYuEsRbDC3eOmC2JvGYLeioxGknL2UATNJN6hmtj1DlpLvDVmocYbrGCVJKOrg4X6DgddLA203BKMFngdJJFtFd7vJLm6KEpc5yjQrkk7M80SGe34X24nSex1Ra5Omgb71JKyg8SrU3i/kARKwWpH0kOGhKkObyfd0ZGjvyXlAkVZ4xRbYJ2irFMkFY1SwyWxr2oo4zlNiV+7zmaweFpT4kR3kaDAFW6xpSqzJay05FtYR4HmZhc9UxKbbfF2V8RG1MBmSaE+kmC6JnaRXK9gsiXhJHl/U0qM0WTcbyhwkYIvFGwjSbjfwhiJt8ZSQU+Bd5+marPMOkVkD0muxYLIfEuhh60x/J92itguihJSEMySVPQnTewnEm+620rTQEMsOfo4/kP/0ARvWjitlpSX7GxBgcMEsd3EEeYWvdytd+Saawi6aCIj1CkGb6Aj9rwhx16Cf3vAwFy5pyLhVonXzy51FDpdEblbkdJbUcEPDEFzQ8qNmhzzLTmmKWKbFCXeEuRabp6rxbvAtLF442QjQ+wEA9eL1xSR7Q0JXzlSHjJ4exq89yR0laScJ/FW6z4a73pFMEfDiRZvuvijIt86RaSFOl01riV2mD1UEvxGk/Geg5aWwGki1zgKPG9J2U8PEg8qYvMsZeytiTRXBMslCU8JSlxi8EabjwUldlDNLfzTUmCgxWsjqWCOHavYAqsknKFIO0yQ61VL5AVFxk6WhEaCAkdJgt9aSkzXlKNX2jEa79waYuc7gq0N3GDJGCBhoiTXUEPsdknCUE1CK0fwsiaylSF2uiDyO4XX3pFhNd7R4itFGc0k/ElBZwWvq+GC6szVeEoS/MZ+qylwpKNKv9Z469UOjqCjwlusicyTxG6VpNxcQ8IncoR4RhLbR+NdpGGmJWOcIzJGUuKPGpQg8rrG21dOMqQssJQ4RxH5jaUqnZuQ0F4Q+cjxLwPtpZbIAk3QTJHQWBE5S1BokoVtDd6lhqr9UpHSUxMcIYl9pojsb8h4SBOsMQcqvOWC2E8EVehqiJ1hrrAEbQxeK0NGZ0Gkq+guSRgniM23bIHVkqwx4hiHd7smaOyglyIyQuM978j4VS08J/A2G1KeMBRo4fBaSNhKUEZfQewVQ/C1I+MgfbEleEzCUw7mKXI0M3hd1EESVji8x5uQ41nxs1q4RMJCCXs7Iq9acpxn22oSDnQ/sJTxsCbHIYZiLyhY05TY0ZLIOQrGaSJDDN4t8pVaIrsqqFdEegtizc1iTew5Q4ayBDMUsQMkXocaYkc0hZua412siZ1rSXlR460zRJ5SlHGe5j801RLMlJTxtaOM3Q1pvxJ45zUlWFD7rsAbpfEm1JHxG0eh8w2R7QQVzBUw28FhFp5QZzq8t2rx2joqulYTWSuJdTYfWwqMFMcovFmSyJPNyLhE4E10pHzYjOC3huArRa571ZsGajQpQx38SBP5pyZB6lMU3khDnp0MBV51BE9o2E+TY5Ml2E8S7C0o6w1xvCZjf0HkVEHCzFoyNmqC+9wdcqN+Tp7jSDheE9ws8Y5V0NJCn2bk2tqSY4okdrEhx1iDN8cSudwepWmAGXKcJXK65H9to8jYQRH7SBF01ESUJdd0TayVInaWhLkOjlXE5irKGOnI6GSWGCJa482zBI9rCr0jyTVcEuzriC1vcr6mwFGSiqy5zMwxBH/TJHwjSPhL8+01kaaSUuMFKTcLEvaUePcrSmwn8DZrgikWb7CGPxkSjhQwrRk57tctmxLsb9sZvL9LSlyuSLlWkqOjwduo8b6Uv1DkmudIeFF2dHCgxVtk8dpIvHpBxhEOdhKk7OLIUSdJ+cSRY57B+0DgGUUlNfpthTfGkauzxrvTsUUaCVhlKeteTXCoJDCa2NOKhOmC4G1H8JBd4OBZReSRGkqcb/CO1PyLJTLB4j1q8JYaIutEjSLX8YKM+a6phdMsdLFUoV5RTm9JSkuDN8WcIon0NZMNZWh1q8C7SJEwV5HxrmnnTrf3KoJBlmCYI2ilSLlfEvlE4011NNgjgthzEua0oKK7JLE7HZHlEl60BLMVFewg4EWNt0ThrVNEVkkiTwpKXSWJzdRENgvKGq4IhjsiezgSFtsfCUq8qki5S1LRQeYQQ4nemmCkImWMw3tFUoUBZk4NOeZYEp4XRKTGa6wJjrWNHBVJR4m3FCnbuD6aak2WsMTh3SZImGCIPKNgsDpVwnsa70K31lCFJZYcwwSMFcQulGTsZuEaSdBXkPGZhu0FsdUO73RHjq8MPGGIfaGIbVTk6iuI3GFgucHrIQkmWSJdBd7BBu+uOryWAhY7+Lki9rK5wtEQzWwvtbqGhIMFwWRJsElsY4m9IIg9L6lCX0VklaPAYkfkZEGDnOWowlBJjtMUkcGK4Lg6EtoZInMUBVYLgn0UsdmCyCz7gIGHFfk+k1QwTh5We7A9x+IdJ6CvIkEagms0hR50eH9UnTQJ+2oiKyVlLFUE+8gBGu8MQ3CppUHesnjTHN4QB/UGPhCTHLFPHMFrCqa73gqObUJGa03wgbhHkrCfpEpzNLE7JDS25FMKhlhKKWKfCgqstLCPu1zBXy0J2ztwjtixBu8UTRn9LVtkmCN2iyFhtME70JHRQ1KVZXqKI/KNIKYMCYs1GUMEKbM1bKOI9LDXC7zbHS+bt+1MTWS9odA9DtrYtpbImQJ2VHh/lisEwaHqUk1kjKTAKknkBEXkbkdMGwq0dnhzLJF3NJH3JVwrqOB4Sca2hti75nmJN0WzxS6UxDYoEpxpa4htVlRjkYE7DZGzJVU72uC9IyhQL4i8YfGWSYLLNcHXloyz7QhNifmKSE9JgfGmuyLhc403Xm9vqcp6gXe3xuuv8F6VJNxkyTHEkHG2g0aKXL0MsXc1bGfgas2//dCONXiNLCX+5mB7eZIl1kHh7ajwpikyzlUUWOVOsjSQlsS+M0R+pPje/dzBXRZGO0rMtgQrLLG9VSu9n6CMXS3BhwYmSoIBhsjNBmZbgusE9BCPCP5triU4VhNbJfE+swSP27aayE8tuTpYYjtrYjMVGZdp2NpS1s6aBnKSHDsbKuplKbHM4a0wMFd/5/DmGyKrJSUaW4IBrqUhx0vyfzTBBLPIUcnZdrAkNsKR0sWRspumSns6Ch0v/qqIbBYUWKvPU/CFoyrDJGwSNFhbA/MlzKqjrO80hRbpKx0Jewsi/STftwGSlKc1JZyAzx05dhLEdnfQvhZOqiHWWEAHC7+30FuRcZUgaO5gpaIK+xsiHRUsqaPElTV40xQZQ107Q9BZE1nryDVGU9ZSQ47bmhBpLcYpUt7S+xuK/FiT8qKjwXYw5ypS2iuCv7q1gtgjhuBuB8LCFY5cUuCNtsQOFcT+4Ih9JX+k8Ea6v0iCIRZOtCT0Et00JW5UeC85Cg0ScK0k411HcG1zKtre3SeITBRk7WfwDhEvaYLTHP9le0m8By0JDwn4TlLW/aJOvGHxdjYUes+ScZigCkYQdNdEOhkiezgShqkx8ueKjI8lDfK2oNiOFvrZH1hS+tk7NV7nOmLHicGWEgubkXKdwdtZknCLJXaCpkrjZBtLZFsDP9CdxWsSr05Sxl6CMmoFbCOgryX40uDtamB7SVmXW4Ihlgpmq+00tBKUUa83WbjLUNkzDmY7cow1JDygyPGlhgGKYKz4vcV7QBNbJIgM11TUqZaMdwTeSguH6rOaw1JRKzaaGyxVm2EJ/uCIrVWUcZUkcp2grMsEjK+DMwS59jQk3Kd6SEq1d0S6uVmO4Bc1lDXTUcHjluCXEq+1OlBDj1pi9zgiXxnKuE0SqTXwhqbETW6RggMEnGl/q49UT2iCzgJvRwVXS2K/d6+ZkyUl7jawSVLit46EwxVljDZwoSQ20sDBihztHfk2yA8NVZghiXwrYHQdfKAOtzsayjhY9bY0yE2CWEeJ9xfzO423xhL5syS2TFJofO2pboHob0nY4GiAgRrvGQEDa/FWSsoaaYl0syRsEt3kWoH3B01shCXhTUWe9w3Bt44SC9QCh3eShQctwbaK2ApLroGCMlZrYqvlY3qYhM0aXpFkPOuoqJ3Dm6fxXrGwVF9gCWZagjPqznfkuMKQ8DPTQRO8ZqG1hPGKEm9IgpGW4DZDgTNriTxvFiq+Lz+0cKfp4wj6OCK9JSnzNSn9LFU7UhKZZMnYwcJ8s8yRsECScK4j5UOB95HFO0CzhY4xJxuCix0lDlEUeMdS6EZBkTsUkZ4K74dugyTXS7aNgL8aqjDfkCE0ZbwkCXpaWCKhl8P7VD5jxykivSyxyZrYERbe168LYu9ZYh86IkscgVLE7tWPKmJv11CgoyJltMEbrohtVAQfO4ImltiHEroYEs7RxAarVpY8AwXMcMReFOTYWe5iiLRQxJ5Q8DtJ8LQhWOhIeFESPGsILhbNDRljNbHzNRlTFbk2S3L0NOS6V1KFJYKUbSTcIIhM0wQ/s2TM0SRMNcQmSap3jCH4yhJZKSkwyRHpYYgsFeQ4U7xoCB7VVOExhXepo9ABBsYbvGWKXPME3lyH95YioZ0gssQRWWbI+FaSMkXijZXwgiTlYdPdkNLaETxlyDVIwqeaEus0aTcYcg0RVOkpR3CSJqIddK+90JCxzsDVloyrFd5ZAr4TBKfaWa6boEA7C7s6EpYaeFPjveooY72mjIccLHJ9HUwVlDhKkmutJDJBwnp1rvulJZggKDRfbXAkvC/4l3ozQOG9a8lxjx0i7nV4jSXc7vhe3OwIxjgSHjdEhhsif9YkPGlus3iLFDnWOFhtCZbJg0UbQcIaR67JjthoCyMEZRwhiXWyxO5QxI6w5NhT4U1WsJvDO60J34fW9hwzwlKij6ZAW9ne4L0s8C6XeBMEkd/LQy1VucBRot6QMlbivaBhoBgjqGiCJNhsqVp/S2SsG6DIONCR0dXhvWbJ+MRRZJkkuEjgDXJjFQW6SSL7GXK8Z2CZg7cVsbWGoKmEpzQ5elpiy8Ryg7dMkLLUEauzeO86CuwlSOlgYLojZWeJ9xM3S1PWfEfKl5ISLQ0MEKR8YOB2QfCxJBjrKPCN4f9MkaSsqoVXJBmP7EpFZ9UQfOoOFwSzBN4MQ8LsGrymlipcJQhmy0GaQjPqCHaXRwuCZwRbqK2Fg9wlClZqYicrIgMdZfxTQ0c7TBIbrChxmuzoKG8XRaSrIhhiyNFJkrC7oIAWMEOQa5aBekPCRknCo4IKPrYkvCDI8aYmY7WFtprgekcJZ3oLIqssCSMtFbQTJKwXYy3BY5oCh2iKPCpJOE+zRdpYgi6O2KmOAgvVCYaU4ySRek1sgyFhJ403QFHiVEmJHwtybO1gs8Hr5+BETQX3War0qZngYGgtVZtoqd6vFSk/UwdZElYqyjrF4HXUeFspIi9IGKf4j92pKGAdCYMVsbcV3kRF0N+R8LUd5PCsIGWoxDtBkCI0nKofdJQxT+LtZflvuc8Q3CjwWkq8KwUpHzkK/NmSsclCL0nseQdj5FRH5CNHSgtLiW80Of5HU9Hhlsga9bnBq3fEVltKfO5IaSTmGjjc4J0otcP7QsJUSQM8pEj5/wCuUuC2DWz8AAAAAElFTkSuQmCC"); +} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/ayu-dark.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/ayu-dark.css new file mode 100644 index 000000000000..13656b94bbf8 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/ayu-dark.css @@ -0,0 +1,44 @@ +/* Based on https://github.com/dempfi/ayu */ + +.cm-s-ayu-dark.CodeMirror { background: #0a0e14; color: #b3b1ad; } +.cm-s-ayu-dark div.CodeMirror-selected { background: #273747; } +.cm-s-ayu-dark .CodeMirror-line::selection, .cm-s-ayu-dark .CodeMirror-line > span::selection, .cm-s-ayu-dark .CodeMirror-line > span > span::selection { background: rgba(39, 55, 71, 99); } +.cm-s-ayu-dark .CodeMirror-line::-moz-selection, .cm-s-ayu-dark .CodeMirror-line > span::-moz-selection, .cm-s-ayu-dark .CodeMirror-line > span > span::-moz-selection { background: rgba(39, 55, 71, 99); } +.cm-s-ayu-dark .CodeMirror-gutters { background: #0a0e14; border-right: 0px; } +.cm-s-ayu-dark .CodeMirror-guttermarker { color: white; } +.cm-s-ayu-dark .CodeMirror-guttermarker-subtle { color: #3d424d; } +.cm-s-ayu-dark .CodeMirror-linenumber { color: #3d424d; } +.cm-s-ayu-dark .CodeMirror-cursor { border-left: 1px solid #e6b450; } +.cm-s-ayu-dark.cm-fat-cursor .CodeMirror-cursor { background-color: #a2a8a175 !important; } +.cm-s-ayu-dark .cm-animate-fat-cursor { background-color: #a2a8a175 !important; } + +.cm-s-ayu-dark span.cm-comment { color: #626a73; } +.cm-s-ayu-dark span.cm-atom { color: #ae81ff; } +.cm-s-ayu-dark span.cm-number { color: #e6b450; } + +.cm-s-ayu-dark span.cm-comment.cm-attribute { color: #ffb454; } +.cm-s-ayu-dark span.cm-comment.cm-def { color: rgba(57, 186, 230, 80); } +.cm-s-ayu-dark span.cm-comment.cm-tag { color: #39bae6; } +.cm-s-ayu-dark span.cm-comment.cm-type { color: #5998a6; } + +.cm-s-ayu-dark span.cm-property, .cm-s-ayu-dark span.cm-attribute { color: #ffb454; } +.cm-s-ayu-dark span.cm-keyword { color: #ff8f40; } +.cm-s-ayu-dark span.cm-builtin { color: #e6b450; } +.cm-s-ayu-dark span.cm-string { color: #c2d94c; } + +.cm-s-ayu-dark span.cm-variable { color: #b3b1ad; } +.cm-s-ayu-dark span.cm-variable-2 { color: #f07178; } +.cm-s-ayu-dark span.cm-variable-3 { color: #39bae6; } +.cm-s-ayu-dark span.cm-type { color: #ff8f40; } +.cm-s-ayu-dark span.cm-def { color: #ffee99; } +.cm-s-ayu-dark span.cm-bracket { color: #f8f8f2; } +.cm-s-ayu-dark span.cm-tag { color: rgba(57, 186, 230, 80); } +.cm-s-ayu-dark span.cm-header { color: #c2d94c; } +.cm-s-ayu-dark span.cm-link { color: #39bae6; } +.cm-s-ayu-dark span.cm-error { color: #ff3333; } + +.cm-s-ayu-dark .CodeMirror-activeline-background { background: #01060e; } +.cm-s-ayu-dark .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/ayu-mirage.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/ayu-mirage.css new file mode 100644 index 000000000000..19403cefb57c --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/ayu-mirage.css @@ -0,0 +1,45 @@ +/* Based on https://github.com/dempfi/ayu */ + +.cm-s-ayu-mirage.CodeMirror { background: #1f2430; color: #cbccc6; } +.cm-s-ayu-mirage div.CodeMirror-selected { background: #34455a; } +.cm-s-ayu-mirage .CodeMirror-line::selection, .cm-s-ayu-mirage .CodeMirror-line > span::selection, .cm-s-ayu-mirage .CodeMirror-line > span > span::selection { background: #34455a; } +.cm-s-ayu-mirage .CodeMirror-line::-moz-selection, .cm-s-ayu-mirage .CodeMirror-line > span::-moz-selection, .cm-s-ayu-mirage .CodeMirror-line > span > span::-moz-selection { background: rgba(25, 30, 42, 99); } +.cm-s-ayu-mirage .CodeMirror-gutters { background: #1f2430; border-right: 0px; } +.cm-s-ayu-mirage .CodeMirror-guttermarker { color: white; } +.cm-s-ayu-mirage .CodeMirror-guttermarker-subtle { color: rgba(112, 122, 140, 66); } +.cm-s-ayu-mirage .CodeMirror-linenumber { color: rgba(61, 66, 77, 99); } +.cm-s-ayu-mirage .CodeMirror-cursor { border-left: 1px solid #ffcc66; } +.cm-s-ayu-mirage.cm-fat-cursor .CodeMirror-cursor {background-color: #a2a8a175 !important;} +.cm-s-ayu-mirage .cm-animate-fat-cursor { background-color: #a2a8a175 !important; } + +.cm-s-ayu-mirage span.cm-comment { color: #5c6773; font-style:italic; } +.cm-s-ayu-mirage span.cm-atom { color: #ae81ff; } +.cm-s-ayu-mirage span.cm-number { color: #ffcc66; } + +.cm-s-ayu-mirage span.cm-comment.cm-attribute { color: #ffd580; } +.cm-s-ayu-mirage span.cm-comment.cm-def { color: #d4bfff; } +.cm-s-ayu-mirage span.cm-comment.cm-tag { color: #5ccfe6; } +.cm-s-ayu-mirage span.cm-comment.cm-type { color: #5998a6; } + +.cm-s-ayu-mirage span.cm-property { color: #f29e74; } +.cm-s-ayu-mirage span.cm-attribute { color: #ffd580; } +.cm-s-ayu-mirage span.cm-keyword { color: #ffa759; } +.cm-s-ayu-mirage span.cm-builtin { color: #ffcc66; } +.cm-s-ayu-mirage span.cm-string { color: #bae67e; } + +.cm-s-ayu-mirage span.cm-variable { color: #cbccc6; } +.cm-s-ayu-mirage span.cm-variable-2 { color: #f28779; } +.cm-s-ayu-mirage span.cm-variable-3 { color: #5ccfe6; } +.cm-s-ayu-mirage span.cm-type { color: #ffa759; } +.cm-s-ayu-mirage span.cm-def { color: #ffd580; } +.cm-s-ayu-mirage span.cm-bracket { color: rgba(92, 207, 230, 80); } +.cm-s-ayu-mirage span.cm-tag { color: #5ccfe6; } +.cm-s-ayu-mirage span.cm-header { color: #bae67e; } +.cm-s-ayu-mirage span.cm-link { color: #5ccfe6; } +.cm-s-ayu-mirage span.cm-error { color: #ff3333; } + +.cm-s-ayu-mirage .CodeMirror-activeline-background { background: #191e2a; } +.cm-s-ayu-mirage .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/base16-dark.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/base16-dark.css new file mode 100644 index 000000000000..b3c31aba20b9 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/base16-dark.css @@ -0,0 +1,40 @@ +/* + + Name: Base16 Default Dark + Author: Chris Kempson (http://chriskempson.com) + + CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror) + Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) + +*/ + +.cm-s-base16-dark.CodeMirror { background: #151515; color: #e0e0e0; } +.cm-s-base16-dark div.CodeMirror-selected { background: #303030; } +.cm-s-base16-dark .CodeMirror-line::selection, .cm-s-base16-dark .CodeMirror-line > span::selection, .cm-s-base16-dark .CodeMirror-line > span > span::selection { background: rgba(48, 48, 48, .99); } +.cm-s-base16-dark .CodeMirror-line::-moz-selection, .cm-s-base16-dark .CodeMirror-line > span::-moz-selection, .cm-s-base16-dark .CodeMirror-line > span > span::-moz-selection { background: rgba(48, 48, 48, .99); } +.cm-s-base16-dark .CodeMirror-gutters { background: #151515; border-right: 0px; } +.cm-s-base16-dark .CodeMirror-guttermarker { color: #ac4142; } +.cm-s-base16-dark .CodeMirror-guttermarker-subtle { color: #505050; } +.cm-s-base16-dark .CodeMirror-linenumber { color: #505050; } +.cm-s-base16-dark .CodeMirror-cursor { border-left: 1px solid #b0b0b0; } +.cm-s-base16-dark.cm-fat-cursor .CodeMirror-cursor { background-color: #8e8d8875 !important; } +.cm-s-base16-dark .cm-animate-fat-cursor { background-color: #8e8d8875 !important; } + +.cm-s-base16-dark span.cm-comment { color: #8f5536; } +.cm-s-base16-dark span.cm-atom { color: #aa759f; } +.cm-s-base16-dark span.cm-number { color: #aa759f; } + +.cm-s-base16-dark span.cm-property, .cm-s-base16-dark span.cm-attribute { color: #90a959; } +.cm-s-base16-dark span.cm-keyword { color: #ac4142; } +.cm-s-base16-dark span.cm-string { color: #f4bf75; } + +.cm-s-base16-dark span.cm-variable { color: #90a959; } +.cm-s-base16-dark span.cm-variable-2 { color: #6a9fb5; } +.cm-s-base16-dark span.cm-def { color: #d28445; } +.cm-s-base16-dark span.cm-bracket { color: #e0e0e0; } +.cm-s-base16-dark span.cm-tag { color: #ac4142; } +.cm-s-base16-dark span.cm-link { color: #aa759f; } +.cm-s-base16-dark span.cm-error { background: #ac4142; color: #b0b0b0; } + +.cm-s-base16-dark .CodeMirror-activeline-background { background: #202020; } +.cm-s-base16-dark .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/base16-light.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/base16-light.css new file mode 100644 index 000000000000..1d5f582f6a98 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/base16-light.css @@ -0,0 +1,38 @@ +/* + + Name: Base16 Default Light + Author: Chris Kempson (http://chriskempson.com) + + CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror) + Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) + +*/ + +.cm-s-base16-light.CodeMirror { background: #f5f5f5; color: #202020; } +.cm-s-base16-light div.CodeMirror-selected { background: #e0e0e0; } +.cm-s-base16-light .CodeMirror-line::selection, .cm-s-base16-light .CodeMirror-line > span::selection, .cm-s-base16-light .CodeMirror-line > span > span::selection { background: #e0e0e0; } +.cm-s-base16-light .CodeMirror-line::-moz-selection, .cm-s-base16-light .CodeMirror-line > span::-moz-selection, .cm-s-base16-light .CodeMirror-line > span > span::-moz-selection { background: #e0e0e0; } +.cm-s-base16-light .CodeMirror-gutters { background: #f5f5f5; border-right: 0px; } +.cm-s-base16-light .CodeMirror-guttermarker { color: #ac4142; } +.cm-s-base16-light .CodeMirror-guttermarker-subtle { color: #b0b0b0; } +.cm-s-base16-light .CodeMirror-linenumber { color: #b0b0b0; } +.cm-s-base16-light .CodeMirror-cursor { border-left: 1px solid #505050; } + +.cm-s-base16-light span.cm-comment { color: #8f5536; } +.cm-s-base16-light span.cm-atom { color: #aa759f; } +.cm-s-base16-light span.cm-number { color: #aa759f; } + +.cm-s-base16-light span.cm-property, .cm-s-base16-light span.cm-attribute { color: #90a959; } +.cm-s-base16-light span.cm-keyword { color: #ac4142; } +.cm-s-base16-light span.cm-string { color: #f4bf75; } + +.cm-s-base16-light span.cm-variable { color: #90a959; } +.cm-s-base16-light span.cm-variable-2 { color: #6a9fb5; } +.cm-s-base16-light span.cm-def { color: #d28445; } +.cm-s-base16-light span.cm-bracket { color: #202020; } +.cm-s-base16-light span.cm-tag { color: #ac4142; } +.cm-s-base16-light span.cm-link { color: #aa759f; } +.cm-s-base16-light span.cm-error { background: #ac4142; color: #505050; } + +.cm-s-base16-light .CodeMirror-activeline-background { background: #DDDCDC; } +.cm-s-base16-light .CodeMirror-matchingbracket { color: #f5f5f5 !important; background-color: #6A9FB5 !important} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/bespin.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/bespin.css new file mode 100644 index 000000000000..3fd3d93a5aba --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/bespin.css @@ -0,0 +1,34 @@ +/* + + Name: Bespin + Author: Mozilla / Jan T. Sott + + CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror) + Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) + +*/ + +.cm-s-bespin.CodeMirror {background: #28211c; color: #9d9b97;} +.cm-s-bespin div.CodeMirror-selected {background: #59554f !important;} +.cm-s-bespin .CodeMirror-gutters {background: #28211c; border-right: 0px;} +.cm-s-bespin .CodeMirror-linenumber {color: #666666;} +.cm-s-bespin .CodeMirror-cursor {border-left: 1px solid #797977 !important;} + +.cm-s-bespin span.cm-comment {color: #937121;} +.cm-s-bespin span.cm-atom {color: #9b859d;} +.cm-s-bespin span.cm-number {color: #9b859d;} + +.cm-s-bespin span.cm-property, .cm-s-bespin span.cm-attribute {color: #54be0d;} +.cm-s-bespin span.cm-keyword {color: #cf6a4c;} +.cm-s-bespin span.cm-string {color: #f9ee98;} + +.cm-s-bespin span.cm-variable {color: #54be0d;} +.cm-s-bespin span.cm-variable-2 {color: #5ea6ea;} +.cm-s-bespin span.cm-def {color: #cf7d34;} +.cm-s-bespin span.cm-error {background: #cf6a4c; color: #797977;} +.cm-s-bespin span.cm-bracket {color: #9d9b97;} +.cm-s-bespin span.cm-tag {color: #cf6a4c;} +.cm-s-bespin span.cm-link {color: #9b859d;} + +.cm-s-bespin .CodeMirror-matchingbracket { text-decoration: underline; color: white !important;} +.cm-s-bespin .CodeMirror-activeline-background { background: #404040; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/blackboard.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/blackboard.css new file mode 100644 index 000000000000..b6eaedb18014 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/blackboard.css @@ -0,0 +1,32 @@ +/* Port of TextMate's Blackboard theme */ + +.cm-s-blackboard.CodeMirror { background: #0C1021; color: #F8F8F8; } +.cm-s-blackboard div.CodeMirror-selected { background: #253B76; } +.cm-s-blackboard .CodeMirror-line::selection, .cm-s-blackboard .CodeMirror-line > span::selection, .cm-s-blackboard .CodeMirror-line > span > span::selection { background: rgba(37, 59, 118, .99); } +.cm-s-blackboard .CodeMirror-line::-moz-selection, .cm-s-blackboard .CodeMirror-line > span::-moz-selection, .cm-s-blackboard .CodeMirror-line > span > span::-moz-selection { background: rgba(37, 59, 118, .99); } +.cm-s-blackboard .CodeMirror-gutters { background: #0C1021; border-right: 0; } +.cm-s-blackboard .CodeMirror-guttermarker { color: #FBDE2D; } +.cm-s-blackboard .CodeMirror-guttermarker-subtle { color: #888; } +.cm-s-blackboard .CodeMirror-linenumber { color: #888; } +.cm-s-blackboard .CodeMirror-cursor { border-left: 1px solid #A7A7A7; } + +.cm-s-blackboard .cm-keyword { color: #FBDE2D; } +.cm-s-blackboard .cm-atom { color: #D8FA3C; } +.cm-s-blackboard .cm-number { color: #D8FA3C; } +.cm-s-blackboard .cm-def { color: #8DA6CE; } +.cm-s-blackboard .cm-variable { color: #FF6400; } +.cm-s-blackboard .cm-operator { color: #FBDE2D; } +.cm-s-blackboard .cm-comment { color: #AEAEAE; } +.cm-s-blackboard .cm-string { color: #61CE3C; } +.cm-s-blackboard .cm-string-2 { color: #61CE3C; } +.cm-s-blackboard .cm-meta { color: #D8FA3C; } +.cm-s-blackboard .cm-builtin { color: #8DA6CE; } +.cm-s-blackboard .cm-tag { color: #8DA6CE; } +.cm-s-blackboard .cm-attribute { color: #8DA6CE; } +.cm-s-blackboard .cm-header { color: #FF6400; } +.cm-s-blackboard .cm-hr { color: #AEAEAE; } +.cm-s-blackboard .cm-link { color: #8DA6CE; } +.cm-s-blackboard .cm-error { background: #9D1E15; color: #F8F8F8; } + +.cm-s-blackboard .CodeMirror-activeline-background { background: #3C3636; } +.cm-s-blackboard .CodeMirror-matchingbracket { outline:1px solid grey;color:white !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/cobalt.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/cobalt.css new file mode 100644 index 000000000000..bbbda3b54382 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/cobalt.css @@ -0,0 +1,25 @@ +.cm-s-cobalt.CodeMirror { background: #002240; color: white; } +.cm-s-cobalt div.CodeMirror-selected { background: #b36539; } +.cm-s-cobalt .CodeMirror-line::selection, .cm-s-cobalt .CodeMirror-line > span::selection, .cm-s-cobalt .CodeMirror-line > span > span::selection { background: rgba(179, 101, 57, .99); } +.cm-s-cobalt .CodeMirror-line::-moz-selection, .cm-s-cobalt .CodeMirror-line > span::-moz-selection, .cm-s-cobalt .CodeMirror-line > span > span::-moz-selection { background: rgba(179, 101, 57, .99); } +.cm-s-cobalt .CodeMirror-gutters { background: #002240; border-right: 1px solid #aaa; } +.cm-s-cobalt .CodeMirror-guttermarker { color: #ffee80; } +.cm-s-cobalt .CodeMirror-guttermarker-subtle { color: #d0d0d0; } +.cm-s-cobalt .CodeMirror-linenumber { color: #d0d0d0; } +.cm-s-cobalt .CodeMirror-cursor { border-left: 1px solid white; } + +.cm-s-cobalt span.cm-comment { color: #08f; } +.cm-s-cobalt span.cm-atom { color: #845dc4; } +.cm-s-cobalt span.cm-number, .cm-s-cobalt span.cm-attribute { color: #ff80e1; } +.cm-s-cobalt span.cm-keyword { color: #ffee80; } +.cm-s-cobalt span.cm-string { color: #3ad900; } +.cm-s-cobalt span.cm-meta { color: #ff9d00; } +.cm-s-cobalt span.cm-variable-2, .cm-s-cobalt span.cm-tag { color: #9effff; } +.cm-s-cobalt span.cm-variable-3, .cm-s-cobalt span.cm-def, .cm-s-cobalt .cm-type { color: white; } +.cm-s-cobalt span.cm-bracket { color: #d8d8d8; } +.cm-s-cobalt span.cm-builtin, .cm-s-cobalt span.cm-special { color: #ff9e59; } +.cm-s-cobalt span.cm-link { color: #845dc4; } +.cm-s-cobalt span.cm-error { color: #9d1e15; } + +.cm-s-cobalt .CodeMirror-activeline-background { background: #002D57; } +.cm-s-cobalt .CodeMirror-matchingbracket { outline:1px solid grey;color:white !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/colorforth.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/colorforth.css new file mode 100644 index 000000000000..19095e41d98c --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/colorforth.css @@ -0,0 +1,33 @@ +.cm-s-colorforth.CodeMirror { background: #000000; color: #f8f8f8; } +.cm-s-colorforth .CodeMirror-gutters { background: #0a001f; border-right: 1px solid #aaa; } +.cm-s-colorforth .CodeMirror-guttermarker { color: #FFBD40; } +.cm-s-colorforth .CodeMirror-guttermarker-subtle { color: #78846f; } +.cm-s-colorforth .CodeMirror-linenumber { color: #bababa; } +.cm-s-colorforth .CodeMirror-cursor { border-left: 1px solid white; } + +.cm-s-colorforth span.cm-comment { color: #ededed; } +.cm-s-colorforth span.cm-def { color: #ff1c1c; font-weight:bold; } +.cm-s-colorforth span.cm-keyword { color: #ffd900; } +.cm-s-colorforth span.cm-builtin { color: #00d95a; } +.cm-s-colorforth span.cm-variable { color: #73ff00; } +.cm-s-colorforth span.cm-string { color: #007bff; } +.cm-s-colorforth span.cm-number { color: #00c4ff; } +.cm-s-colorforth span.cm-atom { color: #606060; } + +.cm-s-colorforth span.cm-variable-2 { color: #EEE; } +.cm-s-colorforth span.cm-variable-3, .cm-s-colorforth span.cm-type { color: #DDD; } +.cm-s-colorforth span.cm-property {} +.cm-s-colorforth span.cm-operator {} + +.cm-s-colorforth span.cm-meta { color: yellow; } +.cm-s-colorforth span.cm-qualifier { color: #FFF700; } +.cm-s-colorforth span.cm-bracket { color: #cc7; } +.cm-s-colorforth span.cm-tag { color: #FFBD40; } +.cm-s-colorforth span.cm-attribute { color: #FFF700; } +.cm-s-colorforth span.cm-error { color: #f00; } + +.cm-s-colorforth div.CodeMirror-selected { background: #333d53; } + +.cm-s-colorforth span.cm-compilation { background: rgba(255, 255, 255, 0.12); } + +.cm-s-colorforth .CodeMirror-activeline-background { background: #253540; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/darcula.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/darcula.css new file mode 100644 index 000000000000..2ec81a355744 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/darcula.css @@ -0,0 +1,53 @@ +/** + Name: IntelliJ IDEA darcula theme + From IntelliJ IDEA by JetBrains + */ + +.cm-s-darcula { font-family: Consolas, Menlo, Monaco, 'Lucida Console', 'Liberation Mono', 'DejaVu Sans Mono', 'Bitstream Vera Sans Mono', 'Courier New', monospace, serif;} +.cm-s-darcula.CodeMirror { background: #2B2B2B; color: #A9B7C6; } + +.cm-s-darcula span.cm-meta { color: #BBB529; } +.cm-s-darcula span.cm-number { color: #6897BB; } +.cm-s-darcula span.cm-keyword { color: #CC7832; line-height: 1em; font-weight: bold; } +.cm-s-darcula span.cm-def { color: #A9B7C6; font-style: italic; } +.cm-s-darcula span.cm-variable { color: #A9B7C6; } +.cm-s-darcula span.cm-variable-2 { color: #A9B7C6; } +.cm-s-darcula span.cm-variable-3 { color: #9876AA; } +.cm-s-darcula span.cm-type { color: #AABBCC; font-weight: bold; } +.cm-s-darcula span.cm-property { color: #FFC66D; } +.cm-s-darcula span.cm-operator { color: #A9B7C6; } +.cm-s-darcula span.cm-string { color: #6A8759; } +.cm-s-darcula span.cm-string-2 { color: #6A8759; } +.cm-s-darcula span.cm-comment { color: #61A151; font-style: italic; } +.cm-s-darcula span.cm-link { color: #CC7832; } +.cm-s-darcula span.cm-atom { color: #CC7832; } +.cm-s-darcula span.cm-error { color: #BC3F3C; } +.cm-s-darcula span.cm-tag { color: #629755; font-weight: bold; font-style: italic; text-decoration: underline; } +.cm-s-darcula span.cm-attribute { color: #6897bb; } +.cm-s-darcula span.cm-qualifier { color: #6A8759; } +.cm-s-darcula span.cm-bracket { color: #A9B7C6; } +.cm-s-darcula span.cm-builtin { color: #FF9E59; } +.cm-s-darcula span.cm-special { color: #FF9E59; } +.cm-s-darcula span.cm-matchhighlight { color: #FFFFFF; background-color: rgba(50, 89, 48, .7); font-weight: normal;} +.cm-s-darcula span.cm-searching { color: #FFFFFF; background-color: rgba(61, 115, 59, .7); font-weight: normal;} + +.cm-s-darcula .CodeMirror-cursor { border-left: 1px solid #A9B7C6; } +.cm-s-darcula .CodeMirror-activeline-background { background: #323232; } +.cm-s-darcula .CodeMirror-gutters { background: #313335; border-right: 1px solid #313335; } +.cm-s-darcula .CodeMirror-guttermarker { color: #FFEE80; } +.cm-s-darcula .CodeMirror-guttermarker-subtle { color: #D0D0D0; } +.cm-s-darcula .CodeMirrir-linenumber { color: #606366; } +.cm-s-darcula .CodeMirror-matchingbracket { background-color: #3B514D; color: #FFEF28 !important; font-weight: bold; } + +.cm-s-darcula div.CodeMirror-selected { background: #214283; } + +.CodeMirror-hints.darcula { + font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; + color: #9C9E9E; + background-color: #3B3E3F !important; +} + +.CodeMirror-hints.darcula .CodeMirror-hint-active { + background-color: #494D4E !important; + color: #9C9E9E !important; +} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/dracula.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/dracula.css new file mode 100644 index 000000000000..253133efe792 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/dracula.css @@ -0,0 +1,40 @@ +/* + + Name: dracula + Author: Michael Kaminsky (http://github.com/mkaminsky11) + + Original dracula color scheme by Zeno Rocha (https://github.com/zenorocha/dracula-theme) + +*/ + + +.cm-s-dracula.CodeMirror, .cm-s-dracula .CodeMirror-gutters { + background-color: #282a36 !important; + color: #f8f8f2 !important; + border: none; +} +.cm-s-dracula .CodeMirror-gutters { color: #282a36; } +.cm-s-dracula .CodeMirror-cursor { border-left: solid thin #f8f8f0; } +.cm-s-dracula .CodeMirror-linenumber { color: #6D8A88; } +.cm-s-dracula .CodeMirror-selected { background: rgba(255, 255, 255, 0.10); } +.cm-s-dracula .CodeMirror-line::selection, .cm-s-dracula .CodeMirror-line > span::selection, .cm-s-dracula .CodeMirror-line > span > span::selection { background: rgba(255, 255, 255, 0.10); } +.cm-s-dracula .CodeMirror-line::-moz-selection, .cm-s-dracula .CodeMirror-line > span::-moz-selection, .cm-s-dracula .CodeMirror-line > span > span::-moz-selection { background: rgba(255, 255, 255, 0.10); } +.cm-s-dracula span.cm-comment { color: #6272a4; } +.cm-s-dracula span.cm-string, .cm-s-dracula span.cm-string-2 { color: #f1fa8c; } +.cm-s-dracula span.cm-number { color: #bd93f9; } +.cm-s-dracula span.cm-variable { color: #50fa7b; } +.cm-s-dracula span.cm-variable-2 { color: white; } +.cm-s-dracula span.cm-def { color: #50fa7b; } +.cm-s-dracula span.cm-operator { color: #ff79c6; } +.cm-s-dracula span.cm-keyword { color: #ff79c6; } +.cm-s-dracula span.cm-atom { color: #bd93f9; } +.cm-s-dracula span.cm-meta { color: #f8f8f2; } +.cm-s-dracula span.cm-tag { color: #ff79c6; } +.cm-s-dracula span.cm-attribute { color: #50fa7b; } +.cm-s-dracula span.cm-qualifier { color: #50fa7b; } +.cm-s-dracula span.cm-property { color: #66d9ef; } +.cm-s-dracula span.cm-builtin { color: #50fa7b; } +.cm-s-dracula span.cm-variable-3, .cm-s-dracula span.cm-type { color: #ffb86c; } + +.cm-s-dracula .CodeMirror-activeline-background { background: rgba(255,255,255,0.1); } +.cm-s-dracula .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/duotone-dark.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/duotone-dark.css new file mode 100644 index 000000000000..5373178e8d71 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/duotone-dark.css @@ -0,0 +1,35 @@ +/* +Name: DuoTone-Dark +Author: by Bram de Haan, adapted from DuoTone themes by Simurai (http://simurai.com/projects/2016/01/01/duotone-themes) + +CodeMirror template by Jan T. Sott (https://github.com/idleberg), adapted by Bram de Haan (https://github.com/atelierbram/) +*/ + +.cm-s-duotone-dark.CodeMirror { background: #2a2734; color: #6c6783; } +.cm-s-duotone-dark div.CodeMirror-selected { background: #545167!important; } +.cm-s-duotone-dark .CodeMirror-gutters { background: #2a2734; border-right: 0px; } +.cm-s-duotone-dark .CodeMirror-linenumber { color: #545167; } + +/* begin cursor */ +.cm-s-duotone-dark .CodeMirror-cursor { border-left: 1px solid #ffad5c; /* border-left: 1px solid #ffad5c80; */ border-right: .5em solid #ffad5c; /* border-right: .5em solid #ffad5c80; */ opacity: .5; } +.cm-s-duotone-dark .CodeMirror-activeline-background { background: #363342; /* background: #36334280; */ opacity: .5;} +.cm-s-duotone-dark .cm-fat-cursor .CodeMirror-cursor { background: #ffad5c; /* background: #ffad5c80; */ opacity: .5;} +/* end cursor */ + +.cm-s-duotone-dark span.cm-atom, .cm-s-duotone-dark span.cm-number, .cm-s-duotone-dark span.cm-keyword, .cm-s-duotone-dark span.cm-variable, .cm-s-duotone-dark span.cm-attribute, .cm-s-duotone-dark span.cm-quote, .cm-s-duotone-dark span.cm-hr, .cm-s-duotone-dark span.cm-link { color: #ffcc99; } + +.cm-s-duotone-dark span.cm-property { color: #9a86fd; } +.cm-s-duotone-dark span.cm-punctuation, .cm-s-duotone-dark span.cm-unit, .cm-s-duotone-dark span.cm-negative { color: #e09142; } +.cm-s-duotone-dark span.cm-string { color: #ffb870; } +.cm-s-duotone-dark span.cm-operator { color: #ffad5c; } +.cm-s-duotone-dark span.cm-positive { color: #6a51e6; } + +.cm-s-duotone-dark span.cm-variable-2, .cm-s-duotone-dark span.cm-variable-3, .cm-s-duotone-dark span.cm-type, .cm-s-duotone-dark span.cm-string-2, .cm-s-duotone-dark span.cm-url { color: #7a63ee; } +.cm-s-duotone-dark span.cm-def, .cm-s-duotone-dark span.cm-tag, .cm-s-duotone-dark span.cm-builtin, .cm-s-duotone-dark span.cm-qualifier, .cm-s-duotone-dark span.cm-header, .cm-s-duotone-dark span.cm-em { color: #eeebff; } +.cm-s-duotone-dark span.cm-bracket, .cm-s-duotone-dark span.cm-comment { color: #a7a5b2; } + +/* using #f00 red for errors, don't think any of the colorscheme variables will stand out enough, ... maybe by giving it a background-color ... */ +.cm-s-duotone-dark span.cm-error, .cm-s-duotone-dark span.cm-invalidchar { color: #f00; } + +.cm-s-duotone-dark span.cm-header { font-weight: normal; } +.cm-s-duotone-dark .CodeMirror-matchingbracket { text-decoration: underline; color: #eeebff !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/duotone-light.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/duotone-light.css new file mode 100644 index 000000000000..a0a0b8336e8a --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/duotone-light.css @@ -0,0 +1,36 @@ +/* +Name: DuoTone-Light +Author: by Bram de Haan, adapted from DuoTone themes by Simurai (http://simurai.com/projects/2016/01/01/duotone-themes) + +CodeMirror template by Jan T. Sott (https://github.com/idleberg), adapted by Bram de Haan (https://github.com/atelierbram/) +*/ + +.cm-s-duotone-light.CodeMirror { background: #faf8f5; color: #b29762; } +.cm-s-duotone-light div.CodeMirror-selected { background: #e3dcce !important; } +.cm-s-duotone-light .CodeMirror-gutters { background: #faf8f5; border-right: 0px; } +.cm-s-duotone-light .CodeMirror-linenumber { color: #cdc4b1; } + +/* begin cursor */ +.cm-s-duotone-light .CodeMirror-cursor { border-left: 1px solid #93abdc; /* border-left: 1px solid #93abdc80; */ border-right: .5em solid #93abdc; /* border-right: .5em solid #93abdc80; */ opacity: .5; } +.cm-s-duotone-light .CodeMirror-activeline-background { background: #e3dcce; /* background: #e3dcce80; */ opacity: .5; } +.cm-s-duotone-light .cm-fat-cursor .CodeMirror-cursor { background: #93abdc; /* #93abdc80; */ opacity: .5; } +/* end cursor */ + +.cm-s-duotone-light span.cm-atom, .cm-s-duotone-light span.cm-number, .cm-s-duotone-light span.cm-keyword, .cm-s-duotone-light span.cm-variable, .cm-s-duotone-light span.cm-attribute, .cm-s-duotone-light span.cm-quote, .cm-s-duotone-light-light span.cm-hr, .cm-s-duotone-light-light span.cm-link { color: #063289; } + +.cm-s-duotone-light span.cm-property { color: #b29762; } +.cm-s-duotone-light span.cm-punctuation, .cm-s-duotone-light span.cm-unit, .cm-s-duotone-light span.cm-negative { color: #063289; } +.cm-s-duotone-light span.cm-string, .cm-s-duotone-light span.cm-operator { color: #1659df; } +.cm-s-duotone-light span.cm-positive { color: #896724; } + +.cm-s-duotone-light span.cm-variable-2, .cm-s-duotone-light span.cm-variable-3, .cm-s-duotone-light span.cm-type, .cm-s-duotone-light span.cm-string-2, .cm-s-duotone-light span.cm-url { color: #896724; } +.cm-s-duotone-light span.cm-def, .cm-s-duotone-light span.cm-tag, .cm-s-duotone-light span.cm-builtin, .cm-s-duotone-light span.cm-qualifier, .cm-s-duotone-light span.cm-header, .cm-s-duotone-light span.cm-em { color: #2d2006; } +.cm-s-duotone-light span.cm-bracket, .cm-s-duotone-light span.cm-comment { color: #6f6e6a; } + +/* using #f00 red for errors, don't think any of the colorscheme variables will stand out enough, ... maybe by giving it a background-color ... */ +/* .cm-s-duotone-light span.cm-error { background: #896724; color: #728fcb; } */ +.cm-s-duotone-light span.cm-error, .cm-s-duotone-light span.cm-invalidchar { color: #f00; } + +.cm-s-duotone-light span.cm-header { font-weight: normal; } +.cm-s-duotone-light .CodeMirror-matchingbracket { text-decoration: underline; color: #faf8f5 !important; } + diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/eclipse.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/eclipse.css new file mode 100644 index 000000000000..800d603f6d4f --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/eclipse.css @@ -0,0 +1,23 @@ +.cm-s-eclipse span.cm-meta { color: #FF1717; } +.cm-s-eclipse span.cm-keyword { line-height: 1em; font-weight: bold; color: #7F0055; } +.cm-s-eclipse span.cm-atom { color: #219; } +.cm-s-eclipse span.cm-number { color: #164; } +.cm-s-eclipse span.cm-def { color: #00f; } +.cm-s-eclipse span.cm-variable { color: black; } +.cm-s-eclipse span.cm-variable-2 { color: #0000C0; } +.cm-s-eclipse span.cm-variable-3, .cm-s-eclipse span.cm-type { color: #0000C0; } +.cm-s-eclipse span.cm-property { color: black; } +.cm-s-eclipse span.cm-operator { color: black; } +.cm-s-eclipse span.cm-comment { color: #3F7F5F; } +.cm-s-eclipse span.cm-string { color: #2A00FF; } +.cm-s-eclipse span.cm-string-2 { color: #f50; } +.cm-s-eclipse span.cm-qualifier { color: #555; } +.cm-s-eclipse span.cm-builtin { color: #30a; } +.cm-s-eclipse span.cm-bracket { color: #cc7; } +.cm-s-eclipse span.cm-tag { color: #170; } +.cm-s-eclipse span.cm-attribute { color: #00c; } +.cm-s-eclipse span.cm-link { color: #219; } +.cm-s-eclipse span.cm-error { color: #f00; } + +.cm-s-eclipse .CodeMirror-activeline-background { background: #e8f2ff; } +.cm-s-eclipse .CodeMirror-matchingbracket { outline:1px solid grey; color:black !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/elegant.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/elegant.css new file mode 100644 index 000000000000..45b3ea655e80 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/elegant.css @@ -0,0 +1,13 @@ +.cm-s-elegant span.cm-number, .cm-s-elegant span.cm-string, .cm-s-elegant span.cm-atom { color: #762; } +.cm-s-elegant span.cm-comment { color: #262; font-style: italic; line-height: 1em; } +.cm-s-elegant span.cm-meta { color: #555; font-style: italic; line-height: 1em; } +.cm-s-elegant span.cm-variable { color: black; } +.cm-s-elegant span.cm-variable-2 { color: #b11; } +.cm-s-elegant span.cm-qualifier { color: #555; } +.cm-s-elegant span.cm-keyword { color: #730; } +.cm-s-elegant span.cm-builtin { color: #30a; } +.cm-s-elegant span.cm-link { color: #762; } +.cm-s-elegant span.cm-error { background-color: #fdd; } + +.cm-s-elegant .CodeMirror-activeline-background { background: #e8f2ff; } +.cm-s-elegant .CodeMirror-matchingbracket { outline:1px solid grey; color:black !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/erlang-dark.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/erlang-dark.css new file mode 100644 index 000000000000..8c8a4171a607 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/erlang-dark.css @@ -0,0 +1,34 @@ +.cm-s-erlang-dark.CodeMirror { background: #002240; color: white; } +.cm-s-erlang-dark div.CodeMirror-selected { background: #b36539; } +.cm-s-erlang-dark .CodeMirror-line::selection, .cm-s-erlang-dark .CodeMirror-line > span::selection, .cm-s-erlang-dark .CodeMirror-line > span > span::selection { background: rgba(179, 101, 57, .99); } +.cm-s-erlang-dark .CodeMirror-line::-moz-selection, .cm-s-erlang-dark .CodeMirror-line > span::-moz-selection, .cm-s-erlang-dark .CodeMirror-line > span > span::-moz-selection { background: rgba(179, 101, 57, .99); } +.cm-s-erlang-dark .CodeMirror-gutters { background: #002240; border-right: 1px solid #aaa; } +.cm-s-erlang-dark .CodeMirror-guttermarker { color: white; } +.cm-s-erlang-dark .CodeMirror-guttermarker-subtle { color: #d0d0d0; } +.cm-s-erlang-dark .CodeMirror-linenumber { color: #d0d0d0; } +.cm-s-erlang-dark .CodeMirror-cursor { border-left: 1px solid white; } + +.cm-s-erlang-dark span.cm-quote { color: #ccc; } +.cm-s-erlang-dark span.cm-atom { color: #f133f1; } +.cm-s-erlang-dark span.cm-attribute { color: #ff80e1; } +.cm-s-erlang-dark span.cm-bracket { color: #ff9d00; } +.cm-s-erlang-dark span.cm-builtin { color: #eaa; } +.cm-s-erlang-dark span.cm-comment { color: #77f; } +.cm-s-erlang-dark span.cm-def { color: #e7a; } +.cm-s-erlang-dark span.cm-keyword { color: #ffee80; } +.cm-s-erlang-dark span.cm-meta { color: #50fefe; } +.cm-s-erlang-dark span.cm-number { color: #ffd0d0; } +.cm-s-erlang-dark span.cm-operator { color: #d55; } +.cm-s-erlang-dark span.cm-property { color: #ccc; } +.cm-s-erlang-dark span.cm-qualifier { color: #ccc; } +.cm-s-erlang-dark span.cm-special { color: #ffbbbb; } +.cm-s-erlang-dark span.cm-string { color: #3ad900; } +.cm-s-erlang-dark span.cm-string-2 { color: #ccc; } +.cm-s-erlang-dark span.cm-tag { color: #9effff; } +.cm-s-erlang-dark span.cm-variable { color: #50fe50; } +.cm-s-erlang-dark span.cm-variable-2 { color: #e0e; } +.cm-s-erlang-dark span.cm-variable-3, .cm-s-erlang-dark span.cm-type { color: #ccc; } +.cm-s-erlang-dark span.cm-error { color: #9d1e15; } + +.cm-s-erlang-dark .CodeMirror-activeline-background { background: #013461; } +.cm-s-erlang-dark .CodeMirror-matchingbracket { outline:1px solid grey; color:white !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/gruvbox-dark.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/gruvbox-dark.css new file mode 100644 index 000000000000..d712dda08df2 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/gruvbox-dark.css @@ -0,0 +1,39 @@ +/* + + Name: gruvbox-dark + Author: kRkk (https://github.com/krkk) + + Original gruvbox color scheme by Pavel Pertsev (https://github.com/morhetz/gruvbox) + +*/ + +.cm-s-gruvbox-dark.CodeMirror, .cm-s-gruvbox-dark .CodeMirror-gutters { background-color: #282828; color: #bdae93; } +.cm-s-gruvbox-dark .CodeMirror-gutters {background: #282828; border-right: 0px;} +.cm-s-gruvbox-dark .CodeMirror-linenumber {color: #7c6f64;} +.cm-s-gruvbox-dark .CodeMirror-cursor { border-left: 1px solid #ebdbb2; } +.cm-s-gruvbox-dark.cm-fat-cursor .CodeMirror-cursor { background-color: #8e8d8875 !important; } +.cm-s-gruvbox-dark .cm-animate-fat-cursor { background-color: #8e8d8875 !important; } +.cm-s-gruvbox-dark div.CodeMirror-selected { background: #928374; } +.cm-s-gruvbox-dark span.cm-meta { color: #83a598; } + +.cm-s-gruvbox-dark span.cm-comment { color: #928374; } +.cm-s-gruvbox-dark span.cm-number, span.cm-atom { color: #d3869b; } +.cm-s-gruvbox-dark span.cm-keyword { color: #f84934; } + +.cm-s-gruvbox-dark span.cm-variable { color: #ebdbb2; } +.cm-s-gruvbox-dark span.cm-variable-2 { color: #ebdbb2; } +.cm-s-gruvbox-dark span.cm-variable-3, .cm-s-gruvbox-dark span.cm-type { color: #fabd2f; } +.cm-s-gruvbox-dark span.cm-operator { color: #ebdbb2; } +.cm-s-gruvbox-dark span.cm-callee { color: #ebdbb2; } +.cm-s-gruvbox-dark span.cm-def { color: #ebdbb2; } +.cm-s-gruvbox-dark span.cm-property { color: #ebdbb2; } +.cm-s-gruvbox-dark span.cm-string { color: #b8bb26; } +.cm-s-gruvbox-dark span.cm-string-2 { color: #8ec07c; } +.cm-s-gruvbox-dark span.cm-qualifier { color: #8ec07c; } +.cm-s-gruvbox-dark span.cm-attribute { color: #8ec07c; } + +.cm-s-gruvbox-dark .CodeMirror-activeline-background { background: #3c3836; } +.cm-s-gruvbox-dark .CodeMirror-matchingbracket { background: #928374; color:#282828 !important; } + +.cm-s-gruvbox-dark span.cm-builtin { color: #fe8019; } +.cm-s-gruvbox-dark span.cm-tag { color: #fe8019; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/hopscotch.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/hopscotch.css new file mode 100644 index 000000000000..7d05431bdcd7 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/hopscotch.css @@ -0,0 +1,34 @@ +/* + + Name: Hopscotch + Author: Jan T. Sott + + CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror) + Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) + +*/ + +.cm-s-hopscotch.CodeMirror {background: #322931; color: #d5d3d5;} +.cm-s-hopscotch div.CodeMirror-selected {background: #433b42 !important;} +.cm-s-hopscotch .CodeMirror-gutters {background: #322931; border-right: 0px;} +.cm-s-hopscotch .CodeMirror-linenumber {color: #797379;} +.cm-s-hopscotch .CodeMirror-cursor {border-left: 1px solid #989498 !important;} + +.cm-s-hopscotch span.cm-comment {color: #b33508;} +.cm-s-hopscotch span.cm-atom {color: #c85e7c;} +.cm-s-hopscotch span.cm-number {color: #c85e7c;} + +.cm-s-hopscotch span.cm-property, .cm-s-hopscotch span.cm-attribute {color: #8fc13e;} +.cm-s-hopscotch span.cm-keyword {color: #dd464c;} +.cm-s-hopscotch span.cm-string {color: #fdcc59;} + +.cm-s-hopscotch span.cm-variable {color: #8fc13e;} +.cm-s-hopscotch span.cm-variable-2 {color: #1290bf;} +.cm-s-hopscotch span.cm-def {color: #fd8b19;} +.cm-s-hopscotch span.cm-error {background: #dd464c; color: #989498;} +.cm-s-hopscotch span.cm-bracket {color: #d5d3d5;} +.cm-s-hopscotch span.cm-tag {color: #dd464c;} +.cm-s-hopscotch span.cm-link {color: #c85e7c;} + +.cm-s-hopscotch .CodeMirror-matchingbracket { text-decoration: underline; color: white !important;} +.cm-s-hopscotch .CodeMirror-activeline-background { background: #302020; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/icecoder.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/icecoder.css new file mode 100644 index 000000000000..5440fbe27c85 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/icecoder.css @@ -0,0 +1,43 @@ +/* +ICEcoder default theme by Matt Pass, used in code editor available at https://icecoder.net +*/ + +.cm-s-icecoder { color: #666; background: #1d1d1b; } + +.cm-s-icecoder span.cm-keyword { color: #eee; font-weight:bold; } /* off-white 1 */ +.cm-s-icecoder span.cm-atom { color: #e1c76e; } /* yellow */ +.cm-s-icecoder span.cm-number { color: #6cb5d9; } /* blue */ +.cm-s-icecoder span.cm-def { color: #b9ca4a; } /* green */ + +.cm-s-icecoder span.cm-variable { color: #6cb5d9; } /* blue */ +.cm-s-icecoder span.cm-variable-2 { color: #cc1e5c; } /* pink */ +.cm-s-icecoder span.cm-variable-3, .cm-s-icecoder span.cm-type { color: #f9602c; } /* orange */ + +.cm-s-icecoder span.cm-property { color: #eee; } /* off-white 1 */ +.cm-s-icecoder span.cm-operator { color: #9179bb; } /* purple */ +.cm-s-icecoder span.cm-comment { color: #97a3aa; } /* grey-blue */ + +.cm-s-icecoder span.cm-string { color: #b9ca4a; } /* green */ +.cm-s-icecoder span.cm-string-2 { color: #6cb5d9; } /* blue */ + +.cm-s-icecoder span.cm-meta { color: #555; } /* grey */ + +.cm-s-icecoder span.cm-qualifier { color: #555; } /* grey */ +.cm-s-icecoder span.cm-builtin { color: #214e7b; } /* bright blue */ +.cm-s-icecoder span.cm-bracket { color: #cc7; } /* grey-yellow */ + +.cm-s-icecoder span.cm-tag { color: #e8e8e8; } /* off-white 2 */ +.cm-s-icecoder span.cm-attribute { color: #099; } /* teal */ + +.cm-s-icecoder span.cm-header { color: #6a0d6a; } /* purple-pink */ +.cm-s-icecoder span.cm-quote { color: #186718; } /* dark green */ +.cm-s-icecoder span.cm-hr { color: #888; } /* mid-grey */ +.cm-s-icecoder span.cm-link { color: #e1c76e; } /* yellow */ +.cm-s-icecoder span.cm-error { color: #d00; } /* red */ + +.cm-s-icecoder .CodeMirror-cursor { border-left: 1px solid white; } +.cm-s-icecoder div.CodeMirror-selected { color: #fff; background: #037; } +.cm-s-icecoder .CodeMirror-gutters { background: #1d1d1b; min-width: 41px; border-right: 0; } +.cm-s-icecoder .CodeMirror-linenumber { color: #555; cursor: default; } +.cm-s-icecoder .CodeMirror-matchingbracket { color: #fff !important; background: #555 !important; } +.cm-s-icecoder .CodeMirror-activeline-background { background: #000; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/idea.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/idea.css new file mode 100644 index 000000000000..eab36717ad2a --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/idea.css @@ -0,0 +1,42 @@ +/** + Name: IDEA default theme + From IntelliJ IDEA by JetBrains + */ + +.cm-s-idea span.cm-meta { color: #808000; } +.cm-s-idea span.cm-number { color: #0000FF; } +.cm-s-idea span.cm-keyword { line-height: 1em; font-weight: bold; color: #000080; } +.cm-s-idea span.cm-atom { font-weight: bold; color: #000080; } +.cm-s-idea span.cm-def { color: #000000; } +.cm-s-idea span.cm-variable { color: black; } +.cm-s-idea span.cm-variable-2 { color: black; } +.cm-s-idea span.cm-variable-3, .cm-s-idea span.cm-type { color: black; } +.cm-s-idea span.cm-property { color: black; } +.cm-s-idea span.cm-operator { color: black; } +.cm-s-idea span.cm-comment { color: #808080; } +.cm-s-idea span.cm-string { color: #008000; } +.cm-s-idea span.cm-string-2 { color: #008000; } +.cm-s-idea span.cm-qualifier { color: #555; } +.cm-s-idea span.cm-error { color: #FF0000; } +.cm-s-idea span.cm-attribute { color: #0000FF; } +.cm-s-idea span.cm-tag { color: #000080; } +.cm-s-idea span.cm-link { color: #0000FF; } +.cm-s-idea .CodeMirror-activeline-background { background: #FFFAE3; } + +.cm-s-idea span.cm-builtin { color: #30a; } +.cm-s-idea span.cm-bracket { color: #cc7; } +.cm-s-idea { font-family: Consolas, Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, Bitstream Vera Sans Mono, Courier New, monospace, serif;} + + +.cm-s-idea .CodeMirror-matchingbracket { outline:1px solid grey; color:black !important; } + +.CodeMirror-hints.idea { + font-family: Menlo, Monaco, Consolas, 'Courier New', monospace; + color: #616569; + background-color: #ebf3fd !important; +} + +.CodeMirror-hints.idea .CodeMirror-hint-active { + background-color: #a2b8c9 !important; + color: #5c6065 !important; +} \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/isotope.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/isotope.css new file mode 100644 index 000000000000..d0d6263cf4e4 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/isotope.css @@ -0,0 +1,34 @@ +/* + + Name: Isotope + Author: David Desandro / Jan T. Sott + + CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror) + Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) + +*/ + +.cm-s-isotope.CodeMirror {background: #000000; color: #e0e0e0;} +.cm-s-isotope div.CodeMirror-selected {background: #404040 !important;} +.cm-s-isotope .CodeMirror-gutters {background: #000000; border-right: 0px;} +.cm-s-isotope .CodeMirror-linenumber {color: #808080;} +.cm-s-isotope .CodeMirror-cursor {border-left: 1px solid #c0c0c0 !important;} + +.cm-s-isotope span.cm-comment {color: #3300ff;} +.cm-s-isotope span.cm-atom {color: #cc00ff;} +.cm-s-isotope span.cm-number {color: #cc00ff;} + +.cm-s-isotope span.cm-property, .cm-s-isotope span.cm-attribute {color: #33ff00;} +.cm-s-isotope span.cm-keyword {color: #ff0000;} +.cm-s-isotope span.cm-string {color: #ff0099;} + +.cm-s-isotope span.cm-variable {color: #33ff00;} +.cm-s-isotope span.cm-variable-2 {color: #0066ff;} +.cm-s-isotope span.cm-def {color: #ff9900;} +.cm-s-isotope span.cm-error {background: #ff0000; color: #c0c0c0;} +.cm-s-isotope span.cm-bracket {color: #e0e0e0;} +.cm-s-isotope span.cm-tag {color: #ff0000;} +.cm-s-isotope span.cm-link {color: #cc00ff;} + +.cm-s-isotope .CodeMirror-matchingbracket { text-decoration: underline; color: white !important;} +.cm-s-isotope .CodeMirror-activeline-background { background: #202020; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/juejin.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/juejin.css new file mode 100644 index 000000000000..38cf7fe37336 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/juejin.css @@ -0,0 +1,30 @@ +.cm-s-juejin.CodeMirror { + background: #f8f9fa; +} +.cm-s-juejin .cm-header, +.cm-s-juejin .cm-def { + color: #1ba2f0; +} +.cm-s-juejin .cm-comment { + color: #009e9d; +} +.cm-s-juejin .cm-quote, +.cm-s-juejin .cm-link, +.cm-s-juejin .cm-strong, +.cm-s-juejin .cm-attribute { + color: #fd7741; +} +.cm-s-juejin .cm-url, +.cm-s-juejin .cm-keyword, +.cm-s-juejin .cm-builtin { + color: #bb51b8; +} +.cm-s-juejin .cm-hr { + color: #909090; +} +.cm-s-juejin .cm-tag { + color: #107000; +} +.cm-s-juejin .cm-variable-2 { + color: #0050a0; +} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/lesser-dark.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/lesser-dark.css new file mode 100644 index 000000000000..f96bf430c2af --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/lesser-dark.css @@ -0,0 +1,47 @@ +/* +http://lesscss.org/ dark theme +Ported to CodeMirror by Peter Kroon +*/ +.cm-s-lesser-dark { + line-height: 1.3em; +} +.cm-s-lesser-dark.CodeMirror { background: #262626; color: #EBEFE7; text-shadow: 0 -1px 1px #262626; } +.cm-s-lesser-dark div.CodeMirror-selected { background: #45443B; } /* 33322B*/ +.cm-s-lesser-dark .CodeMirror-line::selection, .cm-s-lesser-dark .CodeMirror-line > span::selection, .cm-s-lesser-dark .CodeMirror-line > span > span::selection { background: rgba(69, 68, 59, .99); } +.cm-s-lesser-dark .CodeMirror-line::-moz-selection, .cm-s-lesser-dark .CodeMirror-line > span::-moz-selection, .cm-s-lesser-dark .CodeMirror-line > span > span::-moz-selection { background: rgba(69, 68, 59, .99); } +.cm-s-lesser-dark .CodeMirror-cursor { border-left: 1px solid white; } +.cm-s-lesser-dark pre { padding: 0 8px; }/*editable code holder*/ + +.cm-s-lesser-dark.CodeMirror span.CodeMirror-matchingbracket { color: #7EFC7E; }/*65FC65*/ + +.cm-s-lesser-dark .CodeMirror-gutters { background: #262626; border-right:1px solid #aaa; } +.cm-s-lesser-dark .CodeMirror-guttermarker { color: #599eff; } +.cm-s-lesser-dark .CodeMirror-guttermarker-subtle { color: #777; } +.cm-s-lesser-dark .CodeMirror-linenumber { color: #777; } + +.cm-s-lesser-dark span.cm-header { color: #a0a; } +.cm-s-lesser-dark span.cm-quote { color: #090; } +.cm-s-lesser-dark span.cm-keyword { color: #599eff; } +.cm-s-lesser-dark span.cm-atom { color: #C2B470; } +.cm-s-lesser-dark span.cm-number { color: #B35E4D; } +.cm-s-lesser-dark span.cm-def { color: white; } +.cm-s-lesser-dark span.cm-variable { color:#D9BF8C; } +.cm-s-lesser-dark span.cm-variable-2 { color: #669199; } +.cm-s-lesser-dark span.cm-variable-3, .cm-s-lesser-dark span.cm-type { color: white; } +.cm-s-lesser-dark span.cm-property { color: #92A75C; } +.cm-s-lesser-dark span.cm-operator { color: #92A75C; } +.cm-s-lesser-dark span.cm-comment { color: #666; } +.cm-s-lesser-dark span.cm-string { color: #BCD279; } +.cm-s-lesser-dark span.cm-string-2 { color: #f50; } +.cm-s-lesser-dark span.cm-meta { color: #738C73; } +.cm-s-lesser-dark span.cm-qualifier { color: #555; } +.cm-s-lesser-dark span.cm-builtin { color: #ff9e59; } +.cm-s-lesser-dark span.cm-bracket { color: #EBEFE7; } +.cm-s-lesser-dark span.cm-tag { color: #669199; } +.cm-s-lesser-dark span.cm-attribute { color: #81a4d5; } +.cm-s-lesser-dark span.cm-hr { color: #999; } +.cm-s-lesser-dark span.cm-link { color: #7070E6; } +.cm-s-lesser-dark span.cm-error { color: #9d1e15; } + +.cm-s-lesser-dark .CodeMirror-activeline-background { background: #3C3A3A; } +.cm-s-lesser-dark .CodeMirror-matchingbracket { outline:1px solid grey; color:white !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/liquibyte.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/liquibyte.css new file mode 100644 index 000000000000..393825e0296a --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/liquibyte.css @@ -0,0 +1,95 @@ +.cm-s-liquibyte.CodeMirror { + background-color: #000; + color: #fff; + line-height: 1.2em; + font-size: 1em; +} +.cm-s-liquibyte .CodeMirror-focused .cm-matchhighlight { + text-decoration: underline; + text-decoration-color: #0f0; + text-decoration-style: wavy; +} +.cm-s-liquibyte .cm-trailingspace { + text-decoration: line-through; + text-decoration-color: #f00; + text-decoration-style: dotted; +} +.cm-s-liquibyte .cm-tab { + text-decoration: line-through; + text-decoration-color: #404040; + text-decoration-style: dotted; +} +.cm-s-liquibyte .CodeMirror-gutters { background-color: #262626; border-right: 1px solid #505050; padding-right: 0.8em; } +.cm-s-liquibyte .CodeMirror-gutter-elt div { font-size: 1.2em; } +.cm-s-liquibyte .CodeMirror-guttermarker { } +.cm-s-liquibyte .CodeMirror-guttermarker-subtle { } +.cm-s-liquibyte .CodeMirror-linenumber { color: #606060; padding-left: 0; } +.cm-s-liquibyte .CodeMirror-cursor { border-left: 1px solid #eee; } + +.cm-s-liquibyte span.cm-comment { color: #008000; } +.cm-s-liquibyte span.cm-def { color: #ffaf40; font-weight: bold; } +.cm-s-liquibyte span.cm-keyword { color: #c080ff; font-weight: bold; } +.cm-s-liquibyte span.cm-builtin { color: #ffaf40; font-weight: bold; } +.cm-s-liquibyte span.cm-variable { color: #5967ff; font-weight: bold; } +.cm-s-liquibyte span.cm-string { color: #ff8000; } +.cm-s-liquibyte span.cm-number { color: #0f0; font-weight: bold; } +.cm-s-liquibyte span.cm-atom { color: #bf3030; font-weight: bold; } + +.cm-s-liquibyte span.cm-variable-2 { color: #007f7f; font-weight: bold; } +.cm-s-liquibyte span.cm-variable-3, .cm-s-liquibyte span.cm-type { color: #c080ff; font-weight: bold; } +.cm-s-liquibyte span.cm-property { color: #999; font-weight: bold; } +.cm-s-liquibyte span.cm-operator { color: #fff; } + +.cm-s-liquibyte span.cm-meta { color: #0f0; } +.cm-s-liquibyte span.cm-qualifier { color: #fff700; font-weight: bold; } +.cm-s-liquibyte span.cm-bracket { color: #cc7; } +.cm-s-liquibyte span.cm-tag { color: #ff0; font-weight: bold; } +.cm-s-liquibyte span.cm-attribute { color: #c080ff; font-weight: bold; } +.cm-s-liquibyte span.cm-error { color: #f00; } + +.cm-s-liquibyte div.CodeMirror-selected { background-color: rgba(255, 0, 0, 0.25); } + +.cm-s-liquibyte span.cm-compilation { background-color: rgba(255, 255, 255, 0.12); } + +.cm-s-liquibyte .CodeMirror-activeline-background { background-color: rgba(0, 255, 0, 0.15); } + +/* Default styles for common addons */ +.cm-s-liquibyte .CodeMirror span.CodeMirror-matchingbracket { color: #0f0; font-weight: bold; } +.cm-s-liquibyte .CodeMirror span.CodeMirror-nonmatchingbracket { color: #f00; font-weight: bold; } +.CodeMirror-matchingtag { background-color: rgba(150, 255, 0, .3); } +/* Scrollbars */ +/* Simple */ +.cm-s-liquibyte div.CodeMirror-simplescroll-horizontal div:hover, .cm-s-liquibyte div.CodeMirror-simplescroll-vertical div:hover { + background-color: rgba(80, 80, 80, .7); +} +.cm-s-liquibyte div.CodeMirror-simplescroll-horizontal div, .cm-s-liquibyte div.CodeMirror-simplescroll-vertical div { + background-color: rgba(80, 80, 80, .3); + border: 1px solid #404040; + border-radius: 5px; +} +.cm-s-liquibyte div.CodeMirror-simplescroll-vertical div { + border-top: 1px solid #404040; + border-bottom: 1px solid #404040; +} +.cm-s-liquibyte div.CodeMirror-simplescroll-horizontal div { + border-left: 1px solid #404040; + border-right: 1px solid #404040; +} +.cm-s-liquibyte div.CodeMirror-simplescroll-vertical { + background-color: #262626; +} +.cm-s-liquibyte div.CodeMirror-simplescroll-horizontal { + background-color: #262626; + border-top: 1px solid #404040; +} +/* Overlay */ +.cm-s-liquibyte div.CodeMirror-overlayscroll-horizontal div, div.CodeMirror-overlayscroll-vertical div { + background-color: #404040; + border-radius: 5px; +} +.cm-s-liquibyte div.CodeMirror-overlayscroll-vertical div { + border: 1px solid #404040; +} +.cm-s-liquibyte div.CodeMirror-overlayscroll-horizontal div { + border: 1px solid #404040; +} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/lucario.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/lucario.css new file mode 100644 index 000000000000..17a1551032f9 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/lucario.css @@ -0,0 +1,37 @@ +/* + Name: lucario + Author: Raphael Amorim + + Original Lucario color scheme (https://github.com/raphamorim/lucario) +*/ + +.cm-s-lucario.CodeMirror, .cm-s-lucario .CodeMirror-gutters { + background-color: #2b3e50 !important; + color: #f8f8f2 !important; + border: none; +} +.cm-s-lucario .CodeMirror-gutters { color: #2b3e50; } +.cm-s-lucario .CodeMirror-cursor { border-left: solid thin #E6C845; } +.cm-s-lucario .CodeMirror-linenumber { color: #f8f8f2; } +.cm-s-lucario .CodeMirror-selected { background: #243443; } +.cm-s-lucario .CodeMirror-line::selection, .cm-s-lucario .CodeMirror-line > span::selection, .cm-s-lucario .CodeMirror-line > span > span::selection { background: #243443; } +.cm-s-lucario .CodeMirror-line::-moz-selection, .cm-s-lucario .CodeMirror-line > span::-moz-selection, .cm-s-lucario .CodeMirror-line > span > span::-moz-selection { background: #243443; } +.cm-s-lucario span.cm-comment { color: #5c98cd; } +.cm-s-lucario span.cm-string, .cm-s-lucario span.cm-string-2 { color: #E6DB74; } +.cm-s-lucario span.cm-number { color: #ca94ff; } +.cm-s-lucario span.cm-variable { color: #f8f8f2; } +.cm-s-lucario span.cm-variable-2 { color: #f8f8f2; } +.cm-s-lucario span.cm-def { color: #72C05D; } +.cm-s-lucario span.cm-operator { color: #66D9EF; } +.cm-s-lucario span.cm-keyword { color: #ff6541; } +.cm-s-lucario span.cm-atom { color: #bd93f9; } +.cm-s-lucario span.cm-meta { color: #f8f8f2; } +.cm-s-lucario span.cm-tag { color: #ff6541; } +.cm-s-lucario span.cm-attribute { color: #66D9EF; } +.cm-s-lucario span.cm-qualifier { color: #72C05D; } +.cm-s-lucario span.cm-property { color: #f8f8f2; } +.cm-s-lucario span.cm-builtin { color: #72C05D; } +.cm-s-lucario span.cm-variable-3, .cm-s-lucario span.cm-type { color: #ffb86c; } + +.cm-s-lucario .CodeMirror-activeline-background { background: #243443; } +.cm-s-lucario .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/material-darker.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/material-darker.css new file mode 100644 index 000000000000..45b64efb252e --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/material-darker.css @@ -0,0 +1,135 @@ +/* + Name: material + Author: Mattia Astorino (http://github.com/equinusocio) + Website: https://material-theme.site/ +*/ + +.cm-s-material-darker.CodeMirror { + background-color: #212121; + color: #EEFFFF; +} + +.cm-s-material-darker .CodeMirror-gutters { + background: #212121; + color: #545454; + border: none; +} + +.cm-s-material-darker .CodeMirror-guttermarker, +.cm-s-material-darker .CodeMirror-guttermarker-subtle, +.cm-s-material-darker .CodeMirror-linenumber { + color: #545454; +} + +.cm-s-material-darker .CodeMirror-cursor { + border-left: 1px solid #FFCC00; +} + +.cm-s-material-darker div.CodeMirror-selected { + background: rgba(97, 97, 97, 0.2); +} + +.cm-s-material-darker.CodeMirror-focused div.CodeMirror-selected { + background: rgba(97, 97, 97, 0.2); +} + +.cm-s-material-darker .CodeMirror-line::selection, +.cm-s-material-darker .CodeMirror-line>span::selection, +.cm-s-material-darker .CodeMirror-line>span>span::selection { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material-darker .CodeMirror-line::-moz-selection, +.cm-s-material-darker .CodeMirror-line>span::-moz-selection, +.cm-s-material-darker .CodeMirror-line>span>span::-moz-selection { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material-darker .CodeMirror-activeline-background { + background: rgba(0, 0, 0, 0.5); +} + +.cm-s-material-darker .cm-keyword { + color: #C792EA; +} + +.cm-s-material-darker .cm-operator { + color: #89DDFF; +} + +.cm-s-material-darker .cm-variable-2 { + color: #EEFFFF; +} + +.cm-s-material-darker .cm-variable-3, +.cm-s-material-darker .cm-type { + color: #f07178; +} + +.cm-s-material-darker .cm-builtin { + color: #FFCB6B; +} + +.cm-s-material-darker .cm-atom { + color: #F78C6C; +} + +.cm-s-material-darker .cm-number { + color: #FF5370; +} + +.cm-s-material-darker .cm-def { + color: #82AAFF; +} + +.cm-s-material-darker .cm-string { + color: #C3E88D; +} + +.cm-s-material-darker .cm-string-2 { + color: #f07178; +} + +.cm-s-material-darker .cm-comment { + color: #545454; +} + +.cm-s-material-darker .cm-variable { + color: #f07178; +} + +.cm-s-material-darker .cm-tag { + color: #FF5370; +} + +.cm-s-material-darker .cm-meta { + color: #FFCB6B; +} + +.cm-s-material-darker .cm-attribute { + color: #C792EA; +} + +.cm-s-material-darker .cm-property { + color: #C792EA; +} + +.cm-s-material-darker .cm-qualifier { + color: #DECB6B; +} + +.cm-s-material-darker .cm-variable-3, +.cm-s-material-darker .cm-type { + color: #DECB6B; +} + + +.cm-s-material-darker .cm-error { + color: rgba(255, 255, 255, 1.0); + background-color: #FF5370; +} + +.cm-s-material-darker .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/material-ocean.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/material-ocean.css new file mode 100644 index 000000000000..404178de22e5 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/material-ocean.css @@ -0,0 +1,141 @@ +/* + Name: material + Author: Mattia Astorino (http://github.com/equinusocio) + Website: https://material-theme.site/ +*/ + +.cm-s-material-ocean.CodeMirror { + background-color: #0F111A; + color: #8F93A2; +} + +.cm-s-material-ocean .CodeMirror-gutters { + background: #0F111A; + color: #464B5D; + border: none; +} + +.cm-s-material-ocean .CodeMirror-guttermarker, +.cm-s-material-ocean .CodeMirror-guttermarker-subtle, +.cm-s-material-ocean .CodeMirror-linenumber { + color: #464B5D; +} + +.cm-s-material-ocean .CodeMirror-cursor { + border-left: 1px solid #FFCC00; +} +.cm-s-material-ocean.cm-fat-cursor .CodeMirror-cursor { + background-color: #a2a8a175 !important; +} +.cm-s-material-ocean .cm-animate-fat-cursor { + background-color: #a2a8a175 !important; +} + +.cm-s-material-ocean div.CodeMirror-selected { + background: rgba(113, 124, 180, 0.2); +} + +.cm-s-material-ocean.CodeMirror-focused div.CodeMirror-selected { + background: rgba(113, 124, 180, 0.2); +} + +.cm-s-material-ocean .CodeMirror-line::selection, +.cm-s-material-ocean .CodeMirror-line>span::selection, +.cm-s-material-ocean .CodeMirror-line>span>span::selection { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material-ocean .CodeMirror-line::-moz-selection, +.cm-s-material-ocean .CodeMirror-line>span::-moz-selection, +.cm-s-material-ocean .CodeMirror-line>span>span::-moz-selection { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material-ocean .CodeMirror-activeline-background { + background: rgba(0, 0, 0, 0.5); +} + +.cm-s-material-ocean .cm-keyword { + color: #C792EA; +} + +.cm-s-material-ocean .cm-operator { + color: #89DDFF; +} + +.cm-s-material-ocean .cm-variable-2 { + color: #EEFFFF; +} + +.cm-s-material-ocean .cm-variable-3, +.cm-s-material-ocean .cm-type { + color: #f07178; +} + +.cm-s-material-ocean .cm-builtin { + color: #FFCB6B; +} + +.cm-s-material-ocean .cm-atom { + color: #F78C6C; +} + +.cm-s-material-ocean .cm-number { + color: #FF5370; +} + +.cm-s-material-ocean .cm-def { + color: #82AAFF; +} + +.cm-s-material-ocean .cm-string { + color: #C3E88D; +} + +.cm-s-material-ocean .cm-string-2 { + color: #f07178; +} + +.cm-s-material-ocean .cm-comment { + color: #464B5D; +} + +.cm-s-material-ocean .cm-variable { + color: #f07178; +} + +.cm-s-material-ocean .cm-tag { + color: #FF5370; +} + +.cm-s-material-ocean .cm-meta { + color: #FFCB6B; +} + +.cm-s-material-ocean .cm-attribute { + color: #C792EA; +} + +.cm-s-material-ocean .cm-property { + color: #C792EA; +} + +.cm-s-material-ocean .cm-qualifier { + color: #DECB6B; +} + +.cm-s-material-ocean .cm-variable-3, +.cm-s-material-ocean .cm-type { + color: #DECB6B; +} + + +.cm-s-material-ocean .cm-error { + color: rgba(255, 255, 255, 1.0); + background-color: #FF5370; +} + +.cm-s-material-ocean .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/material-palenight.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/material-palenight.css new file mode 100644 index 000000000000..6712c43a0ef5 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/material-palenight.css @@ -0,0 +1,141 @@ +/* + Name: material + Author: Mattia Astorino (http://github.com/equinusocio) + Website: https://material-theme.site/ +*/ + +.cm-s-material-palenight.CodeMirror { + background-color: #292D3E; + color: #A6ACCD; +} + +.cm-s-material-palenight .CodeMirror-gutters { + background: #292D3E; + color: #676E95; + border: none; +} + +.cm-s-material-palenight .CodeMirror-guttermarker, +.cm-s-material-palenight .CodeMirror-guttermarker-subtle, +.cm-s-material-palenight .CodeMirror-linenumber { + color: #676E95; +} + +.cm-s-material-palenight .CodeMirror-cursor { + border-left: 1px solid #FFCC00; +} +.cm-s-material-palenight.cm-fat-cursor .CodeMirror-cursor { + background-color: #607c8b80 !important; +} +.cm-s-material-palenight .cm-animate-fat-cursor { + background-color: #607c8b80 !important; +} + +.cm-s-material-palenight div.CodeMirror-selected { + background: rgba(113, 124, 180, 0.2); +} + +.cm-s-material-palenight.CodeMirror-focused div.CodeMirror-selected { + background: rgba(113, 124, 180, 0.2); +} + +.cm-s-material-palenight .CodeMirror-line::selection, +.cm-s-material-palenight .CodeMirror-line>span::selection, +.cm-s-material-palenight .CodeMirror-line>span>span::selection { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material-palenight .CodeMirror-line::-moz-selection, +.cm-s-material-palenight .CodeMirror-line>span::-moz-selection, +.cm-s-material-palenight .CodeMirror-line>span>span::-moz-selection { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material-palenight .CodeMirror-activeline-background { + background: rgba(0, 0, 0, 0.5); +} + +.cm-s-material-palenight .cm-keyword { + color: #C792EA; +} + +.cm-s-material-palenight .cm-operator { + color: #89DDFF; +} + +.cm-s-material-palenight .cm-variable-2 { + color: #EEFFFF; +} + +.cm-s-material-palenight .cm-variable-3, +.cm-s-material-palenight .cm-type { + color: #f07178; +} + +.cm-s-material-palenight .cm-builtin { + color: #FFCB6B; +} + +.cm-s-material-palenight .cm-atom { + color: #F78C6C; +} + +.cm-s-material-palenight .cm-number { + color: #FF5370; +} + +.cm-s-material-palenight .cm-def { + color: #82AAFF; +} + +.cm-s-material-palenight .cm-string { + color: #C3E88D; +} + +.cm-s-material-palenight .cm-string-2 { + color: #f07178; +} + +.cm-s-material-palenight .cm-comment { + color: #676E95; +} + +.cm-s-material-palenight .cm-variable { + color: #f07178; +} + +.cm-s-material-palenight .cm-tag { + color: #FF5370; +} + +.cm-s-material-palenight .cm-meta { + color: #FFCB6B; +} + +.cm-s-material-palenight .cm-attribute { + color: #C792EA; +} + +.cm-s-material-palenight .cm-property { + color: #C792EA; +} + +.cm-s-material-palenight .cm-qualifier { + color: #DECB6B; +} + +.cm-s-material-palenight .cm-variable-3, +.cm-s-material-palenight .cm-type { + color: #DECB6B; +} + + +.cm-s-material-palenight .cm-error { + color: rgba(255, 255, 255, 1.0); + background-color: #FF5370; +} + +.cm-s-material-palenight .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/material.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/material.css new file mode 100644 index 000000000000..a7848499a739 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/material.css @@ -0,0 +1,141 @@ +/* + Name: material + Author: Mattia Astorino (http://github.com/equinusocio) + Website: https://material-theme.site/ +*/ + +.cm-s-material.CodeMirror { + background-color: #263238; + color: #EEFFFF; +} + +.cm-s-material .CodeMirror-gutters { + background: #263238; + color: #546E7A; + border: none; +} + +.cm-s-material .CodeMirror-guttermarker, +.cm-s-material .CodeMirror-guttermarker-subtle, +.cm-s-material .CodeMirror-linenumber { + color: #546E7A; +} + +.cm-s-material .CodeMirror-cursor { + border-left: 1px solid #FFCC00; +} +.cm-s-material.cm-fat-cursor .CodeMirror-cursor { + background-color: #5d6d5c80 !important; +} +.cm-s-material .cm-animate-fat-cursor { + background-color: #5d6d5c80 !important; +} + +.cm-s-material div.CodeMirror-selected { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material.CodeMirror-focused div.CodeMirror-selected { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material .CodeMirror-line::selection, +.cm-s-material .CodeMirror-line>span::selection, +.cm-s-material .CodeMirror-line>span>span::selection { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material .CodeMirror-line::-moz-selection, +.cm-s-material .CodeMirror-line>span::-moz-selection, +.cm-s-material .CodeMirror-line>span>span::-moz-selection { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-material .CodeMirror-activeline-background { + background: rgba(0, 0, 0, 0.5); +} + +.cm-s-material .cm-keyword { + color: #C792EA; +} + +.cm-s-material .cm-operator { + color: #89DDFF; +} + +.cm-s-material .cm-variable-2 { + color: #EEFFFF; +} + +.cm-s-material .cm-variable-3, +.cm-s-material .cm-type { + color: #f07178; +} + +.cm-s-material .cm-builtin { + color: #FFCB6B; +} + +.cm-s-material .cm-atom { + color: #F78C6C; +} + +.cm-s-material .cm-number { + color: #FF5370; +} + +.cm-s-material .cm-def { + color: #82AAFF; +} + +.cm-s-material .cm-string { + color: #C3E88D; +} + +.cm-s-material .cm-string-2 { + color: #f07178; +} + +.cm-s-material .cm-comment { + color: #546E7A; +} + +.cm-s-material .cm-variable { + color: #f07178; +} + +.cm-s-material .cm-tag { + color: #FF5370; +} + +.cm-s-material .cm-meta { + color: #FFCB6B; +} + +.cm-s-material .cm-attribute { + color: #C792EA; +} + +.cm-s-material .cm-property { + color: #C792EA; +} + +.cm-s-material .cm-qualifier { + color: #DECB6B; +} + +.cm-s-material .cm-variable-3, +.cm-s-material .cm-type { + color: #DECB6B; +} + + +.cm-s-material .cm-error { + color: rgba(255, 255, 255, 1.0); + background-color: #FF5370; +} + +.cm-s-material .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/mbo.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/mbo.css new file mode 100644 index 000000000000..e164fcf42ae5 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/mbo.css @@ -0,0 +1,37 @@ +/****************************************************************/ +/* Based on mbonaci's Brackets mbo theme */ +/* https://github.com/mbonaci/global/blob/master/Mbo.tmTheme */ +/* Create your own: http://tmtheme-editor.herokuapp.com */ +/****************************************************************/ + +.cm-s-mbo.CodeMirror { background: #2c2c2c; color: #ffffec; } +.cm-s-mbo div.CodeMirror-selected { background: #716C62; } +.cm-s-mbo .CodeMirror-line::selection, .cm-s-mbo .CodeMirror-line > span::selection, .cm-s-mbo .CodeMirror-line > span > span::selection { background: rgba(113, 108, 98, .99); } +.cm-s-mbo .CodeMirror-line::-moz-selection, .cm-s-mbo .CodeMirror-line > span::-moz-selection, .cm-s-mbo .CodeMirror-line > span > span::-moz-selection { background: rgba(113, 108, 98, .99); } +.cm-s-mbo .CodeMirror-gutters { background: #4e4e4e; border-right: 0px; } +.cm-s-mbo .CodeMirror-guttermarker { color: white; } +.cm-s-mbo .CodeMirror-guttermarker-subtle { color: grey; } +.cm-s-mbo .CodeMirror-linenumber { color: #dadada; } +.cm-s-mbo .CodeMirror-cursor { border-left: 1px solid #ffffec; } + +.cm-s-mbo span.cm-comment { color: #95958a; } +.cm-s-mbo span.cm-atom { color: #00a8c6; } +.cm-s-mbo span.cm-number { color: #00a8c6; } + +.cm-s-mbo span.cm-property, .cm-s-mbo span.cm-attribute { color: #9ddfe9; } +.cm-s-mbo span.cm-keyword { color: #ffb928; } +.cm-s-mbo span.cm-string { color: #ffcf6c; } +.cm-s-mbo span.cm-string.cm-property { color: #ffffec; } + +.cm-s-mbo span.cm-variable { color: #ffffec; } +.cm-s-mbo span.cm-variable-2 { color: #00a8c6; } +.cm-s-mbo span.cm-def { color: #ffffec; } +.cm-s-mbo span.cm-bracket { color: #fffffc; font-weight: bold; } +.cm-s-mbo span.cm-tag { color: #9ddfe9; } +.cm-s-mbo span.cm-link { color: #f54b07; } +.cm-s-mbo span.cm-error { border-bottom: #636363; color: #ffffec; } +.cm-s-mbo span.cm-qualifier { color: #ffffec; } + +.cm-s-mbo .CodeMirror-activeline-background { background: #494b41; } +.cm-s-mbo .CodeMirror-matchingbracket { color: #ffb928 !important; } +.cm-s-mbo .CodeMirror-matchingtag { background: rgba(255, 255, 255, .37); } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/mdn-like.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/mdn-like.css new file mode 100644 index 000000000000..622ed3efb74f --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/mdn-like.css @@ -0,0 +1,46 @@ +/* + MDN-LIKE Theme - Mozilla + Ported to CodeMirror by Peter Kroon + Report bugs/issues here: https://github.com/codemirror/CodeMirror/issues + GitHub: @peterkroon + + The mdn-like theme is inspired on the displayed code examples at: https://developer.mozilla.org/en-US/docs/Web/CSS/animation + +*/ +.cm-s-mdn-like.CodeMirror { color: #999; background-color: #fff; } +.cm-s-mdn-like div.CodeMirror-selected { background: #cfc; } +.cm-s-mdn-like .CodeMirror-line::selection, .cm-s-mdn-like .CodeMirror-line > span::selection, .cm-s-mdn-like .CodeMirror-line > span > span::selection { background: #cfc; } +.cm-s-mdn-like .CodeMirror-line::-moz-selection, .cm-s-mdn-like .CodeMirror-line > span::-moz-selection, .cm-s-mdn-like .CodeMirror-line > span > span::-moz-selection { background: #cfc; } + +.cm-s-mdn-like .CodeMirror-gutters { background: #f8f8f8; border-left: 6px solid rgba(0,83,159,0.65); color: #333; } +.cm-s-mdn-like .CodeMirror-linenumber { color: #aaa; padding-left: 8px; } +.cm-s-mdn-like .CodeMirror-cursor { border-left: 2px solid #222; } + +.cm-s-mdn-like .cm-keyword { color: #6262FF; } +.cm-s-mdn-like .cm-atom { color: #F90; } +.cm-s-mdn-like .cm-number { color: #ca7841; } +.cm-s-mdn-like .cm-def { color: #8DA6CE; } +.cm-s-mdn-like span.cm-variable-2, .cm-s-mdn-like span.cm-tag { color: #690; } +.cm-s-mdn-like span.cm-variable-3, .cm-s-mdn-like span.cm-def, .cm-s-mdn-like span.cm-type { color: #07a; } + +.cm-s-mdn-like .cm-variable { color: #07a; } +.cm-s-mdn-like .cm-property { color: #905; } +.cm-s-mdn-like .cm-qualifier { color: #690; } + +.cm-s-mdn-like .cm-operator { color: #cda869; } +.cm-s-mdn-like .cm-comment { color:#777; font-weight:normal; } +.cm-s-mdn-like .cm-string { color:#07a; font-style:italic; } +.cm-s-mdn-like .cm-string-2 { color:#bd6b18; } /*?*/ +.cm-s-mdn-like .cm-meta { color: #000; } /*?*/ +.cm-s-mdn-like .cm-builtin { color: #9B7536; } /*?*/ +.cm-s-mdn-like .cm-tag { color: #997643; } +.cm-s-mdn-like .cm-attribute { color: #d6bb6d; } /*?*/ +.cm-s-mdn-like .cm-header { color: #FF6400; } +.cm-s-mdn-like .cm-hr { color: #AEAEAE; } +.cm-s-mdn-like .cm-link { color:#ad9361; font-style:italic; text-decoration:none; } +.cm-s-mdn-like .cm-error { border-bottom: 1px solid red; } + +div.cm-s-mdn-like .CodeMirror-activeline-background { background: #efefff; } +div.cm-s-mdn-like span.CodeMirror-matchingbracket { outline:1px solid grey; color: inherit; } + +.cm-s-mdn-like.CodeMirror { background-image: url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAFcAAAAyCAYAAAAp8UeFAAAHvklEQVR42s2b63bcNgyEQZCSHCdt2vd/0tWF7I+Q6XgMXiTtuvU5Pl57ZQKkKHzEAOtF5KeIJBGJ8uvL599FRFREZhFx8DeXv8trn68RuGaC8TRfo3SNp9dlDDHedyLyTUTeRWStXKPZrjtpZxaRw5hPqozRs1N8/enzIiQRWcCgy4MUA0f+XWliDhyL8Lfyvx7ei/Ae3iQFHyw7U/59pQVIMEEPEz0G7XiwdRjzSfC3UTtz9vchIntxvry5iMgfIhJoEflOz2CQr3F5h/HfeFe+GTdLaKcu9L8LTeQb/R/7GgbsfKedyNdoHsN31uRPWrfZ5wsj/NzzRQHuToIdU3ahwnsKPxXCjJITuOsi7XLc7SG/v5GdALs7wf8JjTFiB5+QvTEfRyGOfX3Lrx8wxyQi3sNq46O7QahQiCsRFgqddjBouVEHOKDgXAQHD9gJCr5sMKkEdjwsarG/ww3BMHBU7OBjXnzdyY7SfCxf5/z6ATccrwlKuwC/jhznnPF4CgVzhhVf4xp2EixcBActO75iZ8/fM9zAs2OMzKdslgXWJ9XG8PQoOAMA5fGcsvORgv0doBXyHrCwfLJAOwo71QLNkb8n2Pl6EWiR7OCibtkPaz4Kc/0NNAze2gju3zOwekALDaCFPI5vjPFmgGY5AZqyGEvH1x7QfIb8YtxMnA/b+QQ0aQDAwc6JMFg8CbQZ4qoYEEHbRwNojuK3EHwd7VALSgq+MNDKzfT58T8qdpADrgW0GmgcAS1lhzztJmkAzcPNOQbsWEALBDSlMKUG0Eq4CLAQWvEVQ9WU57gZJwZtgPO3r9oBTQ9WO8TjqXINx8R0EYpiZEUWOF3FxkbJkgU9B2f41YBrIj5ZfsQa0M5kTgiAAqM3ShXLgu8XMqcrQBvJ0CL5pnTsfMB13oB8athpAq2XOQmcGmoACCLydx7nToa23ATaSIY2ichfOdPTGxlasXMLaL0MLZAOwAKIM+y8CmicobGdCcbbK9DzN+yYGVoNNI5iUKTMyYOjPse4A8SM1MmcXgU0toOq1yO/v8FOxlASyc7TgeYaAMBJHcY1CcCwGI/TK4AmDbDyKYBBtFUkRwto8gygiQEaByFgJ00BH2M8JWwQS1nafDXQCidWyOI8AcjDCSjCLk8ngObuAm3JAHAdubAmOaK06V8MNEsKPJOhobSprwQa6gD7DclRQdqcwL4zxqgBrQcabUiBLclRDKAlWp+etPkBaNMA0AKlrHwTdEByZAA4GM+SNluSY6wAzcMNewxmgig5Ks0nkrSpBvSaQHMdKTBAnLojOdYyGpQ254602ZILPdTD1hdlggdIm74jbTp8vDwF5ZYUeLWGJpWsh6XNyXgcYwVoJQTEhhTYkxzZjiU5npU2TaB979TQehlaAVq4kaGpiPwwwLkYUuBbQwocyQTv1tA0+1UFWoJF3iv1oq+qoSk8EQdJmwHkziIF7oOZk14EGitibAdjLYYK78H5vZOhtWpoI0ATGHs0Q8OMb4Ey+2bU2UYztCtA0wFAs7TplGLRVQCcqaFdGSPCeTI1QNIC52iWNzof6Uib7xjEp07mNNoUYmVosVItHrHzRlLgBn9LFyRHaQCtVUMbtTNhoXWiTOO9k/V8BdAc1Oq0ArSQs6/5SU0hckNy9NnXqQY0PGYo5dWJ7nINaN6o958FWin27aBaWRka1r5myvLOAm0j30eBJqCxHLReVclxhxOEN2JfDWjxBtAC7MIH1fVaGdoOp4qJYDgKtKPSFNID2gSnGldrCqkFZ+5UeQXQBIRrSwocbdZYQT/2LwRahBPBXoHrB8nxaGROST62DKUbQOMMzZIC9abkuELfQzQALWTnDNAm8KHWFOJgJ5+SHIvTPcmx1xQyZRhNL5Qci689aXMEaN/uNIWkEwDAvFpOZmgsBaaGnbs1NPa1Jm32gBZAIh1pCtG7TSH4aE0y1uVY4uqoFPisGlpP2rSA5qTecWn5agK6BzSpgAyD+wFaqhnYoSZ1Vwr8CmlTQbrcO3ZaX0NAEyMbYaAlyquFoLKK3SPby9CeVUPThrSJmkCAE0CrKUQadi4DrdSlWhmah0YL9z9vClH59YGbHx1J8VZTyAjQepJjmXwAKTDQI3omc3p1U4gDUf6RfcdYfrUp5ClAi2J3Ba6UOXGo+K+bQrjjssitG2SJzshaLwMtXgRagUNpYYoVkMSBLM+9GGiJZMvduG6DRZ4qc04DMPtQQxOjEtACmhO7K1AbNbQDEggZyJwscFpAGwENhoBeUwh3bWolhe8BTYVKxQEWrSUn/uhcM5KhvUu/+eQu0Lzhi+VrK0PrZZNDQKs9cpYUuFYgMVpD4/NxenJTiMCNqdUEUf1qZWjppLT5qSkkUZbCwkbZMSuVnu80hfSkzRbQeqCZSAh6huR4VtoM2gHAlLf72smuWgE+VV7XpE25Ab2WFDgyhnSuKbs4GuGzCjR+tIoUuMFg3kgcWKLTwRqanJQ2W00hAsenfaApRC42hbCvK1SlE0HtE9BGgneJO+ELamitD1YjjOYnNYVcraGhtKkW0EqVVeDx733I2NH581k1NNxNLG0i0IJ8/NjVaOZ0tYZ2Vtr0Xv7tPV3hkWp9EFkgS/J0vosngTaSoaG06WHi+xObQkaAdlbanP8B2+2l0f90LmUAAAAASUVORK5CYII=); } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/midnight.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/midnight.css new file mode 100644 index 000000000000..fc26474a4a7c --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/midnight.css @@ -0,0 +1,39 @@ +/* Based on the theme at http://bonsaiden.github.com/JavaScript-Garden */ + +/**/ +.cm-s-midnight .CodeMirror-activeline-background { background: #253540; } + +.cm-s-midnight.CodeMirror { + background: #0F192A; + color: #D1EDFF; +} + +.cm-s-midnight div.CodeMirror-selected { background: #314D67; } +.cm-s-midnight .CodeMirror-line::selection, .cm-s-midnight .CodeMirror-line > span::selection, .cm-s-midnight .CodeMirror-line > span > span::selection { background: rgba(49, 77, 103, .99); } +.cm-s-midnight .CodeMirror-line::-moz-selection, .cm-s-midnight .CodeMirror-line > span::-moz-selection, .cm-s-midnight .CodeMirror-line > span > span::-moz-selection { background: rgba(49, 77, 103, .99); } +.cm-s-midnight .CodeMirror-gutters { background: #0F192A; border-right: 1px solid; } +.cm-s-midnight .CodeMirror-guttermarker { color: white; } +.cm-s-midnight .CodeMirror-guttermarker-subtle { color: #d0d0d0; } +.cm-s-midnight .CodeMirror-linenumber { color: #D0D0D0; } +.cm-s-midnight .CodeMirror-cursor { border-left: 1px solid #F8F8F0; } + +.cm-s-midnight span.cm-comment { color: #428BDD; } +.cm-s-midnight span.cm-atom { color: #AE81FF; } +.cm-s-midnight span.cm-number { color: #D1EDFF; } + +.cm-s-midnight span.cm-property, .cm-s-midnight span.cm-attribute { color: #A6E22E; } +.cm-s-midnight span.cm-keyword { color: #E83737; } +.cm-s-midnight span.cm-string { color: #1DC116; } + +.cm-s-midnight span.cm-variable { color: #FFAA3E; } +.cm-s-midnight span.cm-variable-2 { color: #FFAA3E; } +.cm-s-midnight span.cm-def { color: #4DD; } +.cm-s-midnight span.cm-bracket { color: #D1EDFF; } +.cm-s-midnight span.cm-tag { color: #449; } +.cm-s-midnight span.cm-link { color: #AE81FF; } +.cm-s-midnight span.cm-error { background: #F92672; color: #F8F8F0; } + +.cm-s-midnight .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/monokai.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/monokai.css new file mode 100644 index 000000000000..cd4cd5572092 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/monokai.css @@ -0,0 +1,41 @@ +/* Based on Sublime Text's Monokai theme */ + +.cm-s-monokai.CodeMirror { background: #272822; color: #f8f8f2; } +.cm-s-monokai div.CodeMirror-selected { background: #49483E; } +.cm-s-monokai .CodeMirror-line::selection, .cm-s-monokai .CodeMirror-line > span::selection, .cm-s-monokai .CodeMirror-line > span > span::selection { background: rgba(73, 72, 62, .99); } +.cm-s-monokai .CodeMirror-line::-moz-selection, .cm-s-monokai .CodeMirror-line > span::-moz-selection, .cm-s-monokai .CodeMirror-line > span > span::-moz-selection { background: rgba(73, 72, 62, .99); } +.cm-s-monokai .CodeMirror-gutters { background: #272822; border-right: 0px; } +.cm-s-monokai .CodeMirror-guttermarker { color: white; } +.cm-s-monokai .CodeMirror-guttermarker-subtle { color: #d0d0d0; } +.cm-s-monokai .CodeMirror-linenumber { color: #d0d0d0; } +.cm-s-monokai .CodeMirror-cursor { border-left: 1px solid #f8f8f0; } + +.cm-s-monokai span.cm-comment { color: #75715e; } +.cm-s-monokai span.cm-atom { color: #ae81ff; } +.cm-s-monokai span.cm-number { color: #ae81ff; } + +.cm-s-monokai span.cm-comment.cm-attribute { color: #97b757; } +.cm-s-monokai span.cm-comment.cm-def { color: #bc9262; } +.cm-s-monokai span.cm-comment.cm-tag { color: #bc6283; } +.cm-s-monokai span.cm-comment.cm-type { color: #5998a6; } + +.cm-s-monokai span.cm-property, .cm-s-monokai span.cm-attribute { color: #a6e22e; } +.cm-s-monokai span.cm-keyword { color: #f92672; } +.cm-s-monokai span.cm-builtin { color: #66d9ef; } +.cm-s-monokai span.cm-string { color: #e6db74; } + +.cm-s-monokai span.cm-variable { color: #f8f8f2; } +.cm-s-monokai span.cm-variable-2 { color: #9effff; } +.cm-s-monokai span.cm-variable-3, .cm-s-monokai span.cm-type { color: #66d9ef; } +.cm-s-monokai span.cm-def { color: #fd971f; } +.cm-s-monokai span.cm-bracket { color: #f8f8f2; } +.cm-s-monokai span.cm-tag { color: #f92672; } +.cm-s-monokai span.cm-header { color: #ae81ff; } +.cm-s-monokai span.cm-link { color: #ae81ff; } +.cm-s-monokai span.cm-error { background: #f92672; color: #f8f8f0; } + +.cm-s-monokai .CodeMirror-activeline-background { background: #373831; } +.cm-s-monokai .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/moxer.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/moxer.css new file mode 100644 index 000000000000..b3ca35e38544 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/moxer.css @@ -0,0 +1,143 @@ +/* + Name: Moxer Theme + Author: Mattia Astorino (http://github.com/equinusocio) + Website: https://github.com/moxer-theme/moxer-code +*/ + +.cm-s-moxer.CodeMirror { + background-color: #090A0F; + color: #8E95B4; + line-height: 1.8; +} + +.cm-s-moxer .CodeMirror-gutters { + background: #090A0F; + color: #35394B; + border: none; +} + +.cm-s-moxer .CodeMirror-guttermarker, +.cm-s-moxer .CodeMirror-guttermarker-subtle, +.cm-s-moxer .CodeMirror-linenumber { + color: #35394B; +} + + +.cm-s-moxer .CodeMirror-cursor { + border-left: 1px solid #FFCC00; +} + +.cm-s-moxer div.CodeMirror-selected { + background: rgba(128, 203, 196, 0.2); +} + +.cm-s-moxer.CodeMirror-focused div.CodeMirror-selected { + background: #212431; +} + +.cm-s-moxer .CodeMirror-line::selection, +.cm-s-moxer .CodeMirror-line>span::selection, +.cm-s-moxer .CodeMirror-line>span>span::selection { + background: #212431; +} + +.cm-s-moxer .CodeMirror-line::-moz-selection, +.cm-s-moxer .CodeMirror-line>span::-moz-selection, +.cm-s-moxer .CodeMirror-line>span>span::-moz-selection { + background: #212431; +} + +.cm-s-moxer .CodeMirror-activeline-background, +.cm-s-moxer .CodeMirror-activeline-gutter .CodeMirror-linenumber { + background: rgba(33, 36, 49, 0.5); +} + +.cm-s-moxer .cm-keyword { + color: #D46C6C; +} + +.cm-s-moxer .cm-operator { + color: #D46C6C; +} + +.cm-s-moxer .cm-variable-2 { + color: #81C5DA; +} + + +.cm-s-moxer .cm-variable-3, +.cm-s-moxer .cm-type { + color: #f07178; +} + +.cm-s-moxer .cm-builtin { + color: #FFCB6B; +} + +.cm-s-moxer .cm-atom { + color: #A99BE2; +} + +.cm-s-moxer .cm-number { + color: #7CA4C0; +} + +.cm-s-moxer .cm-def { + color: #F5DFA5; +} + +.cm-s-moxer .CodeMirror-line .cm-def ~ .cm-def { + color: #81C5DA; +} + +.cm-s-moxer .cm-string { + color: #B2E4AE; +} + +.cm-s-moxer .cm-string-2 { + color: #f07178; +} + +.cm-s-moxer .cm-comment { + color: #3F445A; +} + +.cm-s-moxer .cm-variable { + color: #8E95B4; +} + +.cm-s-moxer .cm-tag { + color: #FF5370; +} + +.cm-s-moxer .cm-meta { + color: #FFCB6B; +} + +.cm-s-moxer .cm-attribute { + color: #C792EA; +} + +.cm-s-moxer .cm-property { + color: #81C5DA; +} + +.cm-s-moxer .cm-qualifier { + color: #DECB6B; +} + +.cm-s-moxer .cm-variable-3, +.cm-s-moxer .cm-type { + color: #DECB6B; +} + + +.cm-s-moxer .cm-error { + color: rgba(255, 255, 255, 1.0); + background-color: #FF5370; +} + +.cm-s-moxer .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/neat.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/neat.css new file mode 100644 index 000000000000..4267b1a37dc8 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/neat.css @@ -0,0 +1,12 @@ +.cm-s-neat span.cm-comment { color: #a86; } +.cm-s-neat span.cm-keyword { line-height: 1em; font-weight: bold; color: blue; } +.cm-s-neat span.cm-string { color: #a22; } +.cm-s-neat span.cm-builtin { line-height: 1em; font-weight: bold; color: #077; } +.cm-s-neat span.cm-special { line-height: 1em; font-weight: bold; color: #0aa; } +.cm-s-neat span.cm-variable { color: black; } +.cm-s-neat span.cm-number, .cm-s-neat span.cm-atom { color: #3a3; } +.cm-s-neat span.cm-meta { color: #555; } +.cm-s-neat span.cm-link { color: #3a3; } + +.cm-s-neat .CodeMirror-activeline-background { background: #e8f2ff; } +.cm-s-neat .CodeMirror-matchingbracket { outline:1px solid grey; color:black !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/neo.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/neo.css new file mode 100644 index 000000000000..b28d5c65fab7 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/neo.css @@ -0,0 +1,43 @@ +/* neo theme for codemirror */ + +/* Color scheme */ + +.cm-s-neo.CodeMirror { + background-color:#ffffff; + color:#2e383c; + line-height:1.4375; +} +.cm-s-neo .cm-comment { color:#75787b; } +.cm-s-neo .cm-keyword, .cm-s-neo .cm-property { color:#1d75b3; } +.cm-s-neo .cm-atom,.cm-s-neo .cm-number { color:#75438a; } +.cm-s-neo .cm-node,.cm-s-neo .cm-tag { color:#9c3328; } +.cm-s-neo .cm-string { color:#b35e14; } +.cm-s-neo .cm-variable,.cm-s-neo .cm-qualifier { color:#047d65; } + + +/* Editor styling */ + +.cm-s-neo pre { + padding:0; +} + +.cm-s-neo .CodeMirror-gutters { + border:none; + border-right:10px solid transparent; + background-color:transparent; +} + +.cm-s-neo .CodeMirror-linenumber { + padding:0; + color:#e0e2e5; +} + +.cm-s-neo .CodeMirror-guttermarker { color: #1d75b3; } +.cm-s-neo .CodeMirror-guttermarker-subtle { color: #e0e2e5; } + +.cm-s-neo .CodeMirror-cursor { + width: auto; + border: 0; + background: rgba(155,157,162,0.37); + z-index: 1; +} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/night.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/night.css new file mode 100644 index 000000000000..f631bf42c748 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/night.css @@ -0,0 +1,27 @@ +/* Loosely based on the Midnight Textmate theme */ + +.cm-s-night.CodeMirror { background: #0a001f; color: #f8f8f8; } +.cm-s-night div.CodeMirror-selected { background: #447; } +.cm-s-night .CodeMirror-line::selection, .cm-s-night .CodeMirror-line > span::selection, .cm-s-night .CodeMirror-line > span > span::selection { background: rgba(68, 68, 119, .99); } +.cm-s-night .CodeMirror-line::-moz-selection, .cm-s-night .CodeMirror-line > span::-moz-selection, .cm-s-night .CodeMirror-line > span > span::-moz-selection { background: rgba(68, 68, 119, .99); } +.cm-s-night .CodeMirror-gutters { background: #0a001f; border-right: 1px solid #aaa; } +.cm-s-night .CodeMirror-guttermarker { color: white; } +.cm-s-night .CodeMirror-guttermarker-subtle { color: #bbb; } +.cm-s-night .CodeMirror-linenumber { color: #f8f8f8; } +.cm-s-night .CodeMirror-cursor { border-left: 1px solid white; } + +.cm-s-night span.cm-comment { color: #8900d1; } +.cm-s-night span.cm-atom { color: #845dc4; } +.cm-s-night span.cm-number, .cm-s-night span.cm-attribute { color: #ffd500; } +.cm-s-night span.cm-keyword { color: #599eff; } +.cm-s-night span.cm-string { color: #37f14a; } +.cm-s-night span.cm-meta { color: #7678e2; } +.cm-s-night span.cm-variable-2, .cm-s-night span.cm-tag { color: #99b2ff; } +.cm-s-night span.cm-variable-3, .cm-s-night span.cm-def, .cm-s-night span.cm-type { color: white; } +.cm-s-night span.cm-bracket { color: #8da6ce; } +.cm-s-night span.cm-builtin, .cm-s-night span.cm-special { color: #ff9e59; } +.cm-s-night span.cm-link { color: #845dc4; } +.cm-s-night span.cm-error { color: #9d1e15; } + +.cm-s-night .CodeMirror-activeline-background { background: #1C005A; } +.cm-s-night .CodeMirror-matchingbracket { outline:1px solid grey; color:white !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/nord.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/nord.css new file mode 100644 index 000000000000..41a8ad778236 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/nord.css @@ -0,0 +1,42 @@ +/* Based on arcticicestudio's Nord theme */ +/* https://github.com/arcticicestudio/nord */ + +.cm-s-nord.CodeMirror { background: #2e3440; color: #d8dee9; } +.cm-s-nord div.CodeMirror-selected { background: #434c5e; } +.cm-s-nord .CodeMirror-line::selection, .cm-s-nord .CodeMirror-line > span::selection, .cm-s-nord .CodeMirror-line > span > span::selection { background: #3b4252; } +.cm-s-nord .CodeMirror-line::-moz-selection, .cm-s-nord .CodeMirror-line > span::-moz-selection, .cm-s-nord .CodeMirror-line > span > span::-moz-selection { background: #3b4252; } +.cm-s-nord .CodeMirror-gutters { background: #2e3440; border-right: 0px; } +.cm-s-nord .CodeMirror-guttermarker { color: #4c566a; } +.cm-s-nord .CodeMirror-guttermarker-subtle { color: #4c566a; } +.cm-s-nord .CodeMirror-linenumber { color: #4c566a; } +.cm-s-nord .CodeMirror-cursor { border-left: 1px solid #f8f8f0; } + +.cm-s-nord span.cm-comment { color: #4c566a; } +.cm-s-nord span.cm-atom { color: #b48ead; } +.cm-s-nord span.cm-number { color: #b48ead; } + +.cm-s-nord span.cm-comment.cm-attribute { color: #97b757; } +.cm-s-nord span.cm-comment.cm-def { color: #bc9262; } +.cm-s-nord span.cm-comment.cm-tag { color: #bc6283; } +.cm-s-nord span.cm-comment.cm-type { color: #5998a6; } + +.cm-s-nord span.cm-property, .cm-s-nord span.cm-attribute { color: #8FBCBB; } +.cm-s-nord span.cm-keyword { color: #81A1C1; } +.cm-s-nord span.cm-builtin { color: #81A1C1; } +.cm-s-nord span.cm-string { color: #A3BE8C; } + +.cm-s-nord span.cm-variable { color: #d8dee9; } +.cm-s-nord span.cm-variable-2 { color: #d8dee9; } +.cm-s-nord span.cm-variable-3, .cm-s-nord span.cm-type { color: #d8dee9; } +.cm-s-nord span.cm-def { color: #8FBCBB; } +.cm-s-nord span.cm-bracket { color: #81A1C1; } +.cm-s-nord span.cm-tag { color: #bf616a; } +.cm-s-nord span.cm-header { color: #b48ead; } +.cm-s-nord span.cm-link { color: #b48ead; } +.cm-s-nord span.cm-error { background: #bf616a; color: #f8f8f0; } + +.cm-s-nord .CodeMirror-activeline-background { background: #3b4252; } +.cm-s-nord .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/oceanic-next.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/oceanic-next.css new file mode 100644 index 000000000000..f3d0d08acb77 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/oceanic-next.css @@ -0,0 +1,46 @@ +/* + + Name: oceanic-next + Author: Filype Pereira (https://github.com/fpereira1) + + Original oceanic-next color scheme by Dmitri Voronianski (https://github.com/voronianski/oceanic-next-color-scheme) + +*/ + +.cm-s-oceanic-next.CodeMirror { background: #304148; color: #f8f8f2; } +.cm-s-oceanic-next div.CodeMirror-selected { background: rgba(101, 115, 126, 0.33); } +.cm-s-oceanic-next .CodeMirror-line::selection, .cm-s-oceanic-next .CodeMirror-line > span::selection, .cm-s-oceanic-next .CodeMirror-line > span > span::selection { background: rgba(101, 115, 126, 0.33); } +.cm-s-oceanic-next .CodeMirror-line::-moz-selection, .cm-s-oceanic-next .CodeMirror-line > span::-moz-selection, .cm-s-oceanic-next .CodeMirror-line > span > span::-moz-selection { background: rgba(101, 115, 126, 0.33); } +.cm-s-oceanic-next .CodeMirror-gutters { background: #304148; border-right: 10px; } +.cm-s-oceanic-next .CodeMirror-guttermarker { color: white; } +.cm-s-oceanic-next .CodeMirror-guttermarker-subtle { color: #d0d0d0; } +.cm-s-oceanic-next .CodeMirror-linenumber { color: #d0d0d0; } +.cm-s-oceanic-next .CodeMirror-cursor { border-left: 1px solid #f8f8f0; } +.cm-s-oceanic-next.cm-fat-cursor .CodeMirror-cursor { background-color: #a2a8a175 !important; } +.cm-s-oceanic-next .cm-animate-fat-cursor { background-color: #a2a8a175 !important; } + +.cm-s-oceanic-next span.cm-comment { color: #65737E; } +.cm-s-oceanic-next span.cm-atom { color: #C594C5; } +.cm-s-oceanic-next span.cm-number { color: #F99157; } + +.cm-s-oceanic-next span.cm-property { color: #99C794; } +.cm-s-oceanic-next span.cm-attribute, +.cm-s-oceanic-next span.cm-keyword { color: #C594C5; } +.cm-s-oceanic-next span.cm-builtin { color: #66d9ef; } +.cm-s-oceanic-next span.cm-string { color: #99C794; } + +.cm-s-oceanic-next span.cm-variable, +.cm-s-oceanic-next span.cm-variable-2, +.cm-s-oceanic-next span.cm-variable-3 { color: #f8f8f2; } +.cm-s-oceanic-next span.cm-def { color: #6699CC; } +.cm-s-oceanic-next span.cm-bracket { color: #5FB3B3; } +.cm-s-oceanic-next span.cm-tag { color: #C594C5; } +.cm-s-oceanic-next span.cm-header { color: #C594C5; } +.cm-s-oceanic-next span.cm-link { color: #C594C5; } +.cm-s-oceanic-next span.cm-error { background: #C594C5; color: #f8f8f0; } + +.cm-s-oceanic-next .CodeMirror-activeline-background { background: rgba(101, 115, 126, 0.33); } +.cm-s-oceanic-next .CodeMirror-matchingbracket { + text-decoration: underline; + color: white !important; +} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/panda-syntax.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/panda-syntax.css new file mode 100644 index 000000000000..de14e911244b --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/panda-syntax.css @@ -0,0 +1,85 @@ +/* + Name: Panda Syntax + Author: Siamak Mokhtari (http://github.com/siamak/) + CodeMirror template by Siamak Mokhtari (https://github.com/siamak/atom-panda-syntax) +*/ +.cm-s-panda-syntax { + background: #292A2B; + color: #E6E6E6; + line-height: 1.5; + font-family: 'Operator Mono', 'Source Code Pro', Menlo, Monaco, Consolas, Courier New, monospace; +} +.cm-s-panda-syntax .CodeMirror-cursor { border-color: #ff2c6d; } +.cm-s-panda-syntax .CodeMirror-activeline-background { + background: rgba(99, 123, 156, 0.1); +} +.cm-s-panda-syntax .CodeMirror-selected { + background: #FFF; +} +.cm-s-panda-syntax .cm-comment { + font-style: italic; + color: #676B79; +} +.cm-s-panda-syntax .cm-operator { + color: #f3f3f3; +} +.cm-s-panda-syntax .cm-string { + color: #19F9D8; +} +.cm-s-panda-syntax .cm-string-2 { + color: #FFB86C; +} + +.cm-s-panda-syntax .cm-tag { + color: #ff2c6d; +} +.cm-s-panda-syntax .cm-meta { + color: #b084eb; +} + +.cm-s-panda-syntax .cm-number { + color: #FFB86C; +} +.cm-s-panda-syntax .cm-atom { + color: #ff2c6d; +} +.cm-s-panda-syntax .cm-keyword { + color: #FF75B5; +} +.cm-s-panda-syntax .cm-variable { + color: #ffb86c; +} +.cm-s-panda-syntax .cm-variable-2 { + color: #ff9ac1; +} +.cm-s-panda-syntax .cm-variable-3, .cm-s-panda-syntax .cm-type { + color: #ff9ac1; +} + +.cm-s-panda-syntax .cm-def { + color: #e6e6e6; +} +.cm-s-panda-syntax .cm-property { + color: #f3f3f3; +} +.cm-s-panda-syntax .cm-unit { + color: #ffb86c; +} + +.cm-s-panda-syntax .cm-attribute { + color: #ffb86c; +} + +.cm-s-panda-syntax .CodeMirror-matchingbracket { + border-bottom: 1px dotted #19F9D8; + padding-bottom: 2px; + color: #e6e6e6; +} +.cm-s-panda-syntax .CodeMirror-gutters { + background: #292a2b; + border-right-color: rgba(255, 255, 255, 0.1); +} +.cm-s-panda-syntax .CodeMirror-linenumber { + color: #e6e6e6; + opacity: 0.6; +} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/paraiso-dark.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/paraiso-dark.css new file mode 100644 index 000000000000..aa9d207e6d7c --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/paraiso-dark.css @@ -0,0 +1,38 @@ +/* + + Name: Paraíso (Dark) + Author: Jan T. Sott + + Color scheme by Jan T. Sott (https://github.com/idleberg/Paraiso-CodeMirror) + Inspired by the art of Rubens LP (http://www.rubenslp.com.br) + +*/ + +.cm-s-paraiso-dark.CodeMirror { background: #2f1e2e; color: #b9b6b0; } +.cm-s-paraiso-dark div.CodeMirror-selected { background: #41323f; } +.cm-s-paraiso-dark .CodeMirror-line::selection, .cm-s-paraiso-dark .CodeMirror-line > span::selection, .cm-s-paraiso-dark .CodeMirror-line > span > span::selection { background: rgba(65, 50, 63, .99); } +.cm-s-paraiso-dark .CodeMirror-line::-moz-selection, .cm-s-paraiso-dark .CodeMirror-line > span::-moz-selection, .cm-s-paraiso-dark .CodeMirror-line > span > span::-moz-selection { background: rgba(65, 50, 63, .99); } +.cm-s-paraiso-dark .CodeMirror-gutters { background: #2f1e2e; border-right: 0px; } +.cm-s-paraiso-dark .CodeMirror-guttermarker { color: #ef6155; } +.cm-s-paraiso-dark .CodeMirror-guttermarker-subtle { color: #776e71; } +.cm-s-paraiso-dark .CodeMirror-linenumber { color: #776e71; } +.cm-s-paraiso-dark .CodeMirror-cursor { border-left: 1px solid #8d8687; } + +.cm-s-paraiso-dark span.cm-comment { color: #e96ba8; } +.cm-s-paraiso-dark span.cm-atom { color: #815ba4; } +.cm-s-paraiso-dark span.cm-number { color: #815ba4; } + +.cm-s-paraiso-dark span.cm-property, .cm-s-paraiso-dark span.cm-attribute { color: #48b685; } +.cm-s-paraiso-dark span.cm-keyword { color: #ef6155; } +.cm-s-paraiso-dark span.cm-string { color: #fec418; } + +.cm-s-paraiso-dark span.cm-variable { color: #48b685; } +.cm-s-paraiso-dark span.cm-variable-2 { color: #06b6ef; } +.cm-s-paraiso-dark span.cm-def { color: #f99b15; } +.cm-s-paraiso-dark span.cm-bracket { color: #b9b6b0; } +.cm-s-paraiso-dark span.cm-tag { color: #ef6155; } +.cm-s-paraiso-dark span.cm-link { color: #815ba4; } +.cm-s-paraiso-dark span.cm-error { background: #ef6155; color: #8d8687; } + +.cm-s-paraiso-dark .CodeMirror-activeline-background { background: #4D344A; } +.cm-s-paraiso-dark .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/paraiso-light.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/paraiso-light.css new file mode 100644 index 000000000000..ae0c755f8984 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/paraiso-light.css @@ -0,0 +1,38 @@ +/* + + Name: Paraíso (Light) + Author: Jan T. Sott + + Color scheme by Jan T. Sott (https://github.com/idleberg/Paraiso-CodeMirror) + Inspired by the art of Rubens LP (http://www.rubenslp.com.br) + +*/ + +.cm-s-paraiso-light.CodeMirror { background: #e7e9db; color: #41323f; } +.cm-s-paraiso-light div.CodeMirror-selected { background: #b9b6b0; } +.cm-s-paraiso-light .CodeMirror-line::selection, .cm-s-paraiso-light .CodeMirror-line > span::selection, .cm-s-paraiso-light .CodeMirror-line > span > span::selection { background: #b9b6b0; } +.cm-s-paraiso-light .CodeMirror-line::-moz-selection, .cm-s-paraiso-light .CodeMirror-line > span::-moz-selection, .cm-s-paraiso-light .CodeMirror-line > span > span::-moz-selection { background: #b9b6b0; } +.cm-s-paraiso-light .CodeMirror-gutters { background: #e7e9db; border-right: 0px; } +.cm-s-paraiso-light .CodeMirror-guttermarker { color: black; } +.cm-s-paraiso-light .CodeMirror-guttermarker-subtle { color: #8d8687; } +.cm-s-paraiso-light .CodeMirror-linenumber { color: #8d8687; } +.cm-s-paraiso-light .CodeMirror-cursor { border-left: 1px solid #776e71; } + +.cm-s-paraiso-light span.cm-comment { color: #e96ba8; } +.cm-s-paraiso-light span.cm-atom { color: #815ba4; } +.cm-s-paraiso-light span.cm-number { color: #815ba4; } + +.cm-s-paraiso-light span.cm-property, .cm-s-paraiso-light span.cm-attribute { color: #48b685; } +.cm-s-paraiso-light span.cm-keyword { color: #ef6155; } +.cm-s-paraiso-light span.cm-string { color: #fec418; } + +.cm-s-paraiso-light span.cm-variable { color: #48b685; } +.cm-s-paraiso-light span.cm-variable-2 { color: #06b6ef; } +.cm-s-paraiso-light span.cm-def { color: #f99b15; } +.cm-s-paraiso-light span.cm-bracket { color: #41323f; } +.cm-s-paraiso-light span.cm-tag { color: #ef6155; } +.cm-s-paraiso-light span.cm-link { color: #815ba4; } +.cm-s-paraiso-light span.cm-error { background: #ef6155; color: #776e71; } + +.cm-s-paraiso-light .CodeMirror-activeline-background { background: #CFD1C4; } +.cm-s-paraiso-light .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/pastel-on-dark.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/pastel-on-dark.css new file mode 100644 index 000000000000..60435dd15e63 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/pastel-on-dark.css @@ -0,0 +1,52 @@ +/** + * Pastel On Dark theme ported from ACE editor + * @license MIT + * @copyright AtomicPages LLC 2014 + * @author Dennis Thompson, AtomicPages LLC + * @version 1.1 + * @source https://github.com/atomicpages/codemirror-pastel-on-dark-theme + */ + +.cm-s-pastel-on-dark.CodeMirror { + background: #2c2827; + color: #8F938F; + line-height: 1.5; +} +.cm-s-pastel-on-dark div.CodeMirror-selected { background: rgba(221,240,255,0.2); } +.cm-s-pastel-on-dark .CodeMirror-line::selection, .cm-s-pastel-on-dark .CodeMirror-line > span::selection, .cm-s-pastel-on-dark .CodeMirror-line > span > span::selection { background: rgba(221,240,255,0.2); } +.cm-s-pastel-on-dark .CodeMirror-line::-moz-selection, .cm-s-pastel-on-dark .CodeMirror-line > span::-moz-selection, .cm-s-pastel-on-dark .CodeMirror-line > span > span::-moz-selection { background: rgba(221,240,255,0.2); } + +.cm-s-pastel-on-dark .CodeMirror-gutters { + background: #34302f; + border-right: 0px; + padding: 0 3px; +} +.cm-s-pastel-on-dark .CodeMirror-guttermarker { color: white; } +.cm-s-pastel-on-dark .CodeMirror-guttermarker-subtle { color: #8F938F; } +.cm-s-pastel-on-dark .CodeMirror-linenumber { color: #8F938F; } +.cm-s-pastel-on-dark .CodeMirror-cursor { border-left: 1px solid #A7A7A7; } +.cm-s-pastel-on-dark span.cm-comment { color: #A6C6FF; } +.cm-s-pastel-on-dark span.cm-atom { color: #DE8E30; } +.cm-s-pastel-on-dark span.cm-number { color: #CCCCCC; } +.cm-s-pastel-on-dark span.cm-property { color: #8F938F; } +.cm-s-pastel-on-dark span.cm-attribute { color: #a6e22e; } +.cm-s-pastel-on-dark span.cm-keyword { color: #AEB2F8; } +.cm-s-pastel-on-dark span.cm-string { color: #66A968; } +.cm-s-pastel-on-dark span.cm-variable { color: #AEB2F8; } +.cm-s-pastel-on-dark span.cm-variable-2 { color: #BEBF55; } +.cm-s-pastel-on-dark span.cm-variable-3, .cm-s-pastel-on-dark span.cm-type { color: #DE8E30; } +.cm-s-pastel-on-dark span.cm-def { color: #757aD8; } +.cm-s-pastel-on-dark span.cm-bracket { color: #f8f8f2; } +.cm-s-pastel-on-dark span.cm-tag { color: #C1C144; } +.cm-s-pastel-on-dark span.cm-link { color: #ae81ff; } +.cm-s-pastel-on-dark span.cm-qualifier,.cm-s-pastel-on-dark span.cm-builtin { color: #C1C144; } +.cm-s-pastel-on-dark span.cm-error { + background: #757aD8; + color: #f8f8f0; +} +.cm-s-pastel-on-dark .CodeMirror-activeline-background { background: rgba(255, 255, 255, 0.031); } +.cm-s-pastel-on-dark .CodeMirror-matchingbracket { + border: 1px solid rgba(255,255,255,0.25); + color: #8F938F !important; + margin: -1px -1px 0 -1px; +} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/railscasts.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/railscasts.css new file mode 100644 index 000000000000..aeff0449d56f --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/railscasts.css @@ -0,0 +1,34 @@ +/* + + Name: Railscasts + Author: Ryan Bates (http://railscasts.com) + + CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror) + Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) + +*/ + +.cm-s-railscasts.CodeMirror {background: #2b2b2b; color: #f4f1ed;} +.cm-s-railscasts div.CodeMirror-selected {background: #272935 !important;} +.cm-s-railscasts .CodeMirror-gutters {background: #2b2b2b; border-right: 0px;} +.cm-s-railscasts .CodeMirror-linenumber {color: #5a647e;} +.cm-s-railscasts .CodeMirror-cursor {border-left: 1px solid #d4cfc9 !important;} + +.cm-s-railscasts span.cm-comment {color: #bc9458;} +.cm-s-railscasts span.cm-atom {color: #b6b3eb;} +.cm-s-railscasts span.cm-number {color: #b6b3eb;} + +.cm-s-railscasts span.cm-property, .cm-s-railscasts span.cm-attribute {color: #a5c261;} +.cm-s-railscasts span.cm-keyword {color: #da4939;} +.cm-s-railscasts span.cm-string {color: #ffc66d;} + +.cm-s-railscasts span.cm-variable {color: #a5c261;} +.cm-s-railscasts span.cm-variable-2 {color: #6d9cbe;} +.cm-s-railscasts span.cm-def {color: #cc7833;} +.cm-s-railscasts span.cm-error {background: #da4939; color: #d4cfc9;} +.cm-s-railscasts span.cm-bracket {color: #f4f1ed;} +.cm-s-railscasts span.cm-tag {color: #da4939;} +.cm-s-railscasts span.cm-link {color: #b6b3eb;} + +.cm-s-railscasts .CodeMirror-matchingbracket { text-decoration: underline; color: white !important;} +.cm-s-railscasts .CodeMirror-activeline-background { background: #303040; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/rubyblue.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/rubyblue.css new file mode 100644 index 000000000000..1f181b06ec27 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/rubyblue.css @@ -0,0 +1,25 @@ +.cm-s-rubyblue.CodeMirror { background: #112435; color: white; } +.cm-s-rubyblue div.CodeMirror-selected { background: #38566F; } +.cm-s-rubyblue .CodeMirror-line::selection, .cm-s-rubyblue .CodeMirror-line > span::selection, .cm-s-rubyblue .CodeMirror-line > span > span::selection { background: rgba(56, 86, 111, 0.99); } +.cm-s-rubyblue .CodeMirror-line::-moz-selection, .cm-s-rubyblue .CodeMirror-line > span::-moz-selection, .cm-s-rubyblue .CodeMirror-line > span > span::-moz-selection { background: rgba(56, 86, 111, 0.99); } +.cm-s-rubyblue .CodeMirror-gutters { background: #1F4661; border-right: 7px solid #3E7087; } +.cm-s-rubyblue .CodeMirror-guttermarker { color: white; } +.cm-s-rubyblue .CodeMirror-guttermarker-subtle { color: #3E7087; } +.cm-s-rubyblue .CodeMirror-linenumber { color: white; } +.cm-s-rubyblue .CodeMirror-cursor { border-left: 1px solid white; } + +.cm-s-rubyblue span.cm-comment { color: #999; font-style:italic; line-height: 1em; } +.cm-s-rubyblue span.cm-atom { color: #F4C20B; } +.cm-s-rubyblue span.cm-number, .cm-s-rubyblue span.cm-attribute { color: #82C6E0; } +.cm-s-rubyblue span.cm-keyword { color: #F0F; } +.cm-s-rubyblue span.cm-string { color: #F08047; } +.cm-s-rubyblue span.cm-meta { color: #F0F; } +.cm-s-rubyblue span.cm-variable-2, .cm-s-rubyblue span.cm-tag { color: #7BD827; } +.cm-s-rubyblue span.cm-variable-3, .cm-s-rubyblue span.cm-def, .cm-s-rubyblue span.cm-type { color: white; } +.cm-s-rubyblue span.cm-bracket { color: #F0F; } +.cm-s-rubyblue span.cm-link { color: #F4C20B; } +.cm-s-rubyblue span.CodeMirror-matchingbracket { color:#F0F !important; } +.cm-s-rubyblue span.cm-builtin, .cm-s-rubyblue span.cm-special { color: #FF9D00; } +.cm-s-rubyblue span.cm-error { color: #AF2018; } + +.cm-s-rubyblue .CodeMirror-activeline-background { background: #173047; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/seti.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/seti.css new file mode 100644 index 000000000000..814f76f7dece --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/seti.css @@ -0,0 +1,44 @@ +/* + + Name: seti + Author: Michael Kaminsky (http://github.com/mkaminsky11) + + Original seti color scheme by Jesse Weed (https://github.com/jesseweed/seti-syntax) + +*/ + + +.cm-s-seti.CodeMirror { + background-color: #151718 !important; + color: #CFD2D1 !important; + border: none; +} +.cm-s-seti .CodeMirror-gutters { + color: #404b53; + background-color: #0E1112; + border: none; +} +.cm-s-seti .CodeMirror-cursor { border-left: solid thin #f8f8f0; } +.cm-s-seti .CodeMirror-linenumber { color: #6D8A88; } +.cm-s-seti.CodeMirror-focused div.CodeMirror-selected { background: rgba(255, 255, 255, 0.10); } +.cm-s-seti .CodeMirror-line::selection, .cm-s-seti .CodeMirror-line > span::selection, .cm-s-seti .CodeMirror-line > span > span::selection { background: rgba(255, 255, 255, 0.10); } +.cm-s-seti .CodeMirror-line::-moz-selection, .cm-s-seti .CodeMirror-line > span::-moz-selection, .cm-s-seti .CodeMirror-line > span > span::-moz-selection { background: rgba(255, 255, 255, 0.10); } +.cm-s-seti span.cm-comment { color: #41535b; } +.cm-s-seti span.cm-string, .cm-s-seti span.cm-string-2 { color: #55b5db; } +.cm-s-seti span.cm-number { color: #cd3f45; } +.cm-s-seti span.cm-variable { color: #55b5db; } +.cm-s-seti span.cm-variable-2 { color: #a074c4; } +.cm-s-seti span.cm-def { color: #55b5db; } +.cm-s-seti span.cm-keyword { color: #ff79c6; } +.cm-s-seti span.cm-operator { color: #9fca56; } +.cm-s-seti span.cm-keyword { color: #e6cd69; } +.cm-s-seti span.cm-atom { color: #cd3f45; } +.cm-s-seti span.cm-meta { color: #55b5db; } +.cm-s-seti span.cm-tag { color: #55b5db; } +.cm-s-seti span.cm-attribute { color: #9fca56; } +.cm-s-seti span.cm-qualifier { color: #9fca56; } +.cm-s-seti span.cm-property { color: #a074c4; } +.cm-s-seti span.cm-variable-3, .cm-s-seti span.cm-type { color: #9fca56; } +.cm-s-seti span.cm-builtin { color: #9fca56; } +.cm-s-seti .CodeMirror-activeline-background { background: #101213; } +.cm-s-seti .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/shadowfox.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/shadowfox.css new file mode 100644 index 000000000000..32d59b139ac2 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/shadowfox.css @@ -0,0 +1,52 @@ +/* + + Name: shadowfox + Author: overdodactyl (http://github.com/overdodactyl) + + Original shadowfox color scheme by Firefox + +*/ + +.cm-s-shadowfox.CodeMirror { background: #2a2a2e; color: #b1b1b3; } +.cm-s-shadowfox div.CodeMirror-selected { background: #353B48; } +.cm-s-shadowfox .CodeMirror-line::selection, .cm-s-shadowfox .CodeMirror-line > span::selection, .cm-s-shadowfox .CodeMirror-line > span > span::selection { background: #353B48; } +.cm-s-shadowfox .CodeMirror-line::-moz-selection, .cm-s-shadowfox .CodeMirror-line > span::-moz-selection, .cm-s-shadowfox .CodeMirror-line > span > span::-moz-selection { background: #353B48; } +.cm-s-shadowfox .CodeMirror-gutters { background: #0c0c0d ; border-right: 1px solid #0c0c0d; } +.cm-s-shadowfox .CodeMirror-guttermarker { color: #555; } +.cm-s-shadowfox .CodeMirror-linenumber { color: #939393; } +.cm-s-shadowfox .CodeMirror-cursor { border-left: 1px solid #fff; } + +.cm-s-shadowfox span.cm-comment { color: #939393; } +.cm-s-shadowfox span.cm-atom { color: #FF7DE9; } +.cm-s-shadowfox span.cm-quote { color: #FF7DE9; } +.cm-s-shadowfox span.cm-builtin { color: #FF7DE9; } +.cm-s-shadowfox span.cm-attribute { color: #FF7DE9; } +.cm-s-shadowfox span.cm-keyword { color: #FF7DE9; } +.cm-s-shadowfox span.cm-error { color: #FF7DE9; } + +.cm-s-shadowfox span.cm-number { color: #6B89FF; } +.cm-s-shadowfox span.cm-string { color: #6B89FF; } +.cm-s-shadowfox span.cm-string-2 { color: #6B89FF; } + +.cm-s-shadowfox span.cm-meta { color: #939393; } +.cm-s-shadowfox span.cm-hr { color: #939393; } + +.cm-s-shadowfox span.cm-header { color: #75BFFF; } +.cm-s-shadowfox span.cm-qualifier { color: #75BFFF; } +.cm-s-shadowfox span.cm-variable-2 { color: #75BFFF; } + +.cm-s-shadowfox span.cm-property { color: #86DE74; } + +.cm-s-shadowfox span.cm-def { color: #75BFFF; } +.cm-s-shadowfox span.cm-bracket { color: #75BFFF; } +.cm-s-shadowfox span.cm-tag { color: #75BFFF; } +.cm-s-shadowfox span.cm-link:visited { color: #75BFFF; } + +.cm-s-shadowfox span.cm-variable { color: #B98EFF; } +.cm-s-shadowfox span.cm-variable-3 { color: #d7d7db; } +.cm-s-shadowfox span.cm-link { color: #737373; } +.cm-s-shadowfox span.cm-operator { color: #b1b1b3; } +.cm-s-shadowfox span.cm-special { color: #d7d7db; } + +.cm-s-shadowfox .CodeMirror-activeline-background { background: rgba(185, 215, 253, .15) } +.cm-s-shadowfox .CodeMirror-matchingbracket { outline: solid 1px rgba(255, 255, 255, .25); color: white !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/solarized.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/solarized.css new file mode 100644 index 000000000000..e978fec9b0a1 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/solarized.css @@ -0,0 +1,165 @@ +/* +Solarized theme for code-mirror +http://ethanschoonover.com/solarized +*/ + +/* +Solarized color palette +http://ethanschoonover.com/solarized/img/solarized-palette.png +*/ + +.solarized.base03 { color: #002b36; } +.solarized.base02 { color: #073642; } +.solarized.base01 { color: #586e75; } +.solarized.base00 { color: #657b83; } +.solarized.base0 { color: #839496; } +.solarized.base1 { color: #93a1a1; } +.solarized.base2 { color: #eee8d5; } +.solarized.base3 { color: #fdf6e3; } +.solarized.solar-yellow { color: #b58900; } +.solarized.solar-orange { color: #cb4b16; } +.solarized.solar-red { color: #dc322f; } +.solarized.solar-magenta { color: #d33682; } +.solarized.solar-violet { color: #6c71c4; } +.solarized.solar-blue { color: #268bd2; } +.solarized.solar-cyan { color: #2aa198; } +.solarized.solar-green { color: #859900; } + +/* Color scheme for code-mirror */ + +.cm-s-solarized { + line-height: 1.45em; + color-profile: sRGB; + rendering-intent: auto; +} +.cm-s-solarized.cm-s-dark { + color: #839496; + background-color: #002b36; +} +.cm-s-solarized.cm-s-light { + background-color: #fdf6e3; + color: #657b83; +} + +.cm-s-solarized .CodeMirror-widget { + text-shadow: none; +} + +.cm-s-solarized .cm-header { color: #586e75; } +.cm-s-solarized .cm-quote { color: #93a1a1; } + +.cm-s-solarized .cm-keyword { color: #cb4b16; } +.cm-s-solarized .cm-atom { color: #d33682; } +.cm-s-solarized .cm-number { color: #d33682; } +.cm-s-solarized .cm-def { color: #2aa198; } + +.cm-s-solarized .cm-variable { color: #839496; } +.cm-s-solarized .cm-variable-2 { color: #b58900; } +.cm-s-solarized .cm-variable-3, .cm-s-solarized .cm-type { color: #6c71c4; } + +.cm-s-solarized .cm-property { color: #2aa198; } +.cm-s-solarized .cm-operator { color: #6c71c4; } + +.cm-s-solarized .cm-comment { color: #586e75; font-style:italic; } + +.cm-s-solarized .cm-string { color: #859900; } +.cm-s-solarized .cm-string-2 { color: #b58900; } + +.cm-s-solarized .cm-meta { color: #859900; } +.cm-s-solarized .cm-qualifier { color: #b58900; } +.cm-s-solarized .cm-builtin { color: #d33682; } +.cm-s-solarized .cm-bracket { color: #cb4b16; } +.cm-s-solarized .CodeMirror-matchingbracket { color: #859900; } +.cm-s-solarized .CodeMirror-nonmatchingbracket { color: #dc322f; } +.cm-s-solarized .cm-tag { color: #93a1a1; } +.cm-s-solarized .cm-attribute { color: #2aa198; } +.cm-s-solarized .cm-hr { + color: transparent; + border-top: 1px solid #586e75; + display: block; +} +.cm-s-solarized .cm-link { color: #93a1a1; cursor: pointer; } +.cm-s-solarized .cm-special { color: #6c71c4; } +.cm-s-solarized .cm-em { + color: #999; + text-decoration: underline; + text-decoration-style: dotted; +} +.cm-s-solarized .cm-error, +.cm-s-solarized .cm-invalidchar { + color: #586e75; + border-bottom: 1px dotted #dc322f; +} + +.cm-s-solarized.cm-s-dark div.CodeMirror-selected { background: #073642; } +.cm-s-solarized.cm-s-dark.CodeMirror ::selection { background: rgba(7, 54, 66, 0.99); } +.cm-s-solarized.cm-s-dark .CodeMirror-line::-moz-selection, .cm-s-dark .CodeMirror-line > span::-moz-selection, .cm-s-dark .CodeMirror-line > span > span::-moz-selection { background: rgba(7, 54, 66, 0.99); } + +.cm-s-solarized.cm-s-light div.CodeMirror-selected { background: #eee8d5; } +.cm-s-solarized.cm-s-light .CodeMirror-line::selection, .cm-s-light .CodeMirror-line > span::selection, .cm-s-light .CodeMirror-line > span > span::selection { background: #eee8d5; } +.cm-s-solarized.cm-s-light .CodeMirror-line::-moz-selection, .cm-s-light .CodeMirror-line > span::-moz-selection, .cm-s-light .CodeMirror-line > span > span::-moz-selection { background: #eee8d5; } + +/* Editor styling */ + + + +/* Little shadow on the view-port of the buffer view */ +.cm-s-solarized.CodeMirror { + -moz-box-shadow: inset 7px 0 12px -6px #000; + -webkit-box-shadow: inset 7px 0 12px -6px #000; + box-shadow: inset 7px 0 12px -6px #000; +} + +/* Remove gutter border */ +.cm-s-solarized .CodeMirror-gutters { + border-right: 0; +} + +/* Gutter colors and line number styling based of color scheme (dark / light) */ + +/* Dark */ +.cm-s-solarized.cm-s-dark .CodeMirror-gutters { + background-color: #073642; +} + +.cm-s-solarized.cm-s-dark .CodeMirror-linenumber { + color: #586e75; +} + +/* Light */ +.cm-s-solarized.cm-s-light .CodeMirror-gutters { + background-color: #eee8d5; +} + +.cm-s-solarized.cm-s-light .CodeMirror-linenumber { + color: #839496; +} + +/* Common */ +.cm-s-solarized .CodeMirror-linenumber { + padding: 0 5px; +} +.cm-s-solarized .CodeMirror-guttermarker-subtle { color: #586e75; } +.cm-s-solarized.cm-s-dark .CodeMirror-guttermarker { color: #ddd; } +.cm-s-solarized.cm-s-light .CodeMirror-guttermarker { color: #cb4b16; } + +.cm-s-solarized .CodeMirror-gutter .CodeMirror-gutter-text { + color: #586e75; +} + +/* Cursor */ +.cm-s-solarized .CodeMirror-cursor { border-left: 1px solid #819090; } + +/* Fat cursor */ +.cm-s-solarized.cm-s-light.cm-fat-cursor .CodeMirror-cursor { background: #77ee77; } +.cm-s-solarized.cm-s-light .cm-animate-fat-cursor { background-color: #77ee77; } +.cm-s-solarized.cm-s-dark.cm-fat-cursor .CodeMirror-cursor { background: #586e75; } +.cm-s-solarized.cm-s-dark .cm-animate-fat-cursor { background-color: #586e75; } + +/* Active line */ +.cm-s-solarized.cm-s-dark .CodeMirror-activeline-background { + background: rgba(255, 255, 255, 0.06); +} +.cm-s-solarized.cm-s-light .CodeMirror-activeline-background { + background: rgba(0, 0, 0, 0.06); +} diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/ssms.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/ssms.css new file mode 100644 index 000000000000..9494c14c20f0 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/ssms.css @@ -0,0 +1,16 @@ +.cm-s-ssms span.cm-keyword { color: blue; } +.cm-s-ssms span.cm-comment { color: darkgreen; } +.cm-s-ssms span.cm-string { color: red; } +.cm-s-ssms span.cm-def { color: black; } +.cm-s-ssms span.cm-variable { color: black; } +.cm-s-ssms span.cm-variable-2 { color: black; } +.cm-s-ssms span.cm-atom { color: darkgray; } +.cm-s-ssms .CodeMirror-linenumber { color: teal; } +.cm-s-ssms .CodeMirror-activeline-background { background: #ffffff; } +.cm-s-ssms span.cm-string-2 { color: #FF00FF; } +.cm-s-ssms span.cm-operator, +.cm-s-ssms span.cm-bracket, +.cm-s-ssms span.cm-punctuation { color: darkgray; } +.cm-s-ssms .CodeMirror-gutters { border-right: 3px solid #ffee62; background-color: #ffffff; } +.cm-s-ssms div.CodeMirror-selected { background: #ADD6FF; } + diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/the-matrix.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/the-matrix.css new file mode 100644 index 000000000000..c4c93c11eafd --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/the-matrix.css @@ -0,0 +1,30 @@ +.cm-s-the-matrix.CodeMirror { background: #000000; color: #00FF00; } +.cm-s-the-matrix div.CodeMirror-selected { background: #2D2D2D; } +.cm-s-the-matrix .CodeMirror-line::selection, .cm-s-the-matrix .CodeMirror-line > span::selection, .cm-s-the-matrix .CodeMirror-line > span > span::selection { background: rgba(45, 45, 45, 0.99); } +.cm-s-the-matrix .CodeMirror-line::-moz-selection, .cm-s-the-matrix .CodeMirror-line > span::-moz-selection, .cm-s-the-matrix .CodeMirror-line > span > span::-moz-selection { background: rgba(45, 45, 45, 0.99); } +.cm-s-the-matrix .CodeMirror-gutters { background: #060; border-right: 2px solid #00FF00; } +.cm-s-the-matrix .CodeMirror-guttermarker { color: #0f0; } +.cm-s-the-matrix .CodeMirror-guttermarker-subtle { color: white; } +.cm-s-the-matrix .CodeMirror-linenumber { color: #FFFFFF; } +.cm-s-the-matrix .CodeMirror-cursor { border-left: 1px solid #00FF00; } + +.cm-s-the-matrix span.cm-keyword { color: #008803; font-weight: bold; } +.cm-s-the-matrix span.cm-atom { color: #3FF; } +.cm-s-the-matrix span.cm-number { color: #FFB94F; } +.cm-s-the-matrix span.cm-def { color: #99C; } +.cm-s-the-matrix span.cm-variable { color: #F6C; } +.cm-s-the-matrix span.cm-variable-2 { color: #C6F; } +.cm-s-the-matrix span.cm-variable-3, .cm-s-the-matrix span.cm-type { color: #96F; } +.cm-s-the-matrix span.cm-property { color: #62FFA0; } +.cm-s-the-matrix span.cm-operator { color: #999; } +.cm-s-the-matrix span.cm-comment { color: #CCCCCC; } +.cm-s-the-matrix span.cm-string { color: #39C; } +.cm-s-the-matrix span.cm-meta { color: #C9F; } +.cm-s-the-matrix span.cm-qualifier { color: #FFF700; } +.cm-s-the-matrix span.cm-builtin { color: #30a; } +.cm-s-the-matrix span.cm-bracket { color: #cc7; } +.cm-s-the-matrix span.cm-tag { color: #FFBD40; } +.cm-s-the-matrix span.cm-attribute { color: #FFF700; } +.cm-s-the-matrix span.cm-error { color: #FF0000; } + +.cm-s-the-matrix .CodeMirror-activeline-background { background: #040; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/tomorrow-night-bright.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/tomorrow-night-bright.css new file mode 100644 index 000000000000..b6dd4a92787a --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/tomorrow-night-bright.css @@ -0,0 +1,35 @@ +/* + + Name: Tomorrow Night - Bright + Author: Chris Kempson + + Port done by Gerard Braad + +*/ + +.cm-s-tomorrow-night-bright.CodeMirror { background: #000000; color: #eaeaea; } +.cm-s-tomorrow-night-bright div.CodeMirror-selected { background: #424242; } +.cm-s-tomorrow-night-bright .CodeMirror-gutters { background: #000000; border-right: 0px; } +.cm-s-tomorrow-night-bright .CodeMirror-guttermarker { color: #e78c45; } +.cm-s-tomorrow-night-bright .CodeMirror-guttermarker-subtle { color: #777; } +.cm-s-tomorrow-night-bright .CodeMirror-linenumber { color: #424242; } +.cm-s-tomorrow-night-bright .CodeMirror-cursor { border-left: 1px solid #6A6A6A; } + +.cm-s-tomorrow-night-bright span.cm-comment { color: #d27b53; } +.cm-s-tomorrow-night-bright span.cm-atom { color: #a16a94; } +.cm-s-tomorrow-night-bright span.cm-number { color: #a16a94; } + +.cm-s-tomorrow-night-bright span.cm-property, .cm-s-tomorrow-night-bright span.cm-attribute { color: #99cc99; } +.cm-s-tomorrow-night-bright span.cm-keyword { color: #d54e53; } +.cm-s-tomorrow-night-bright span.cm-string { color: #e7c547; } + +.cm-s-tomorrow-night-bright span.cm-variable { color: #b9ca4a; } +.cm-s-tomorrow-night-bright span.cm-variable-2 { color: #7aa6da; } +.cm-s-tomorrow-night-bright span.cm-def { color: #e78c45; } +.cm-s-tomorrow-night-bright span.cm-bracket { color: #eaeaea; } +.cm-s-tomorrow-night-bright span.cm-tag { color: #d54e53; } +.cm-s-tomorrow-night-bright span.cm-link { color: #a16a94; } +.cm-s-tomorrow-night-bright span.cm-error { background: #d54e53; color: #6A6A6A; } + +.cm-s-tomorrow-night-bright .CodeMirror-activeline-background { background: #2a2a2a; } +.cm-s-tomorrow-night-bright .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/tomorrow-night-eighties.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/tomorrow-night-eighties.css new file mode 100644 index 000000000000..2a9debc32713 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/tomorrow-night-eighties.css @@ -0,0 +1,38 @@ +/* + + Name: Tomorrow Night - Eighties + Author: Chris Kempson + + CodeMirror template by Jan T. Sott (https://github.com/idleberg/base16-codemirror) + Original Base16 color scheme by Chris Kempson (https://github.com/chriskempson/base16) + +*/ + +.cm-s-tomorrow-night-eighties.CodeMirror { background: #000000; color: #CCCCCC; } +.cm-s-tomorrow-night-eighties div.CodeMirror-selected { background: #2D2D2D; } +.cm-s-tomorrow-night-eighties .CodeMirror-line::selection, .cm-s-tomorrow-night-eighties .CodeMirror-line > span::selection, .cm-s-tomorrow-night-eighties .CodeMirror-line > span > span::selection { background: rgba(45, 45, 45, 0.99); } +.cm-s-tomorrow-night-eighties .CodeMirror-line::-moz-selection, .cm-s-tomorrow-night-eighties .CodeMirror-line > span::-moz-selection, .cm-s-tomorrow-night-eighties .CodeMirror-line > span > span::-moz-selection { background: rgba(45, 45, 45, 0.99); } +.cm-s-tomorrow-night-eighties .CodeMirror-gutters { background: #000000; border-right: 0px; } +.cm-s-tomorrow-night-eighties .CodeMirror-guttermarker { color: #f2777a; } +.cm-s-tomorrow-night-eighties .CodeMirror-guttermarker-subtle { color: #777; } +.cm-s-tomorrow-night-eighties .CodeMirror-linenumber { color: #515151; } +.cm-s-tomorrow-night-eighties .CodeMirror-cursor { border-left: 1px solid #6A6A6A; } + +.cm-s-tomorrow-night-eighties span.cm-comment { color: #d27b53; } +.cm-s-tomorrow-night-eighties span.cm-atom { color: #a16a94; } +.cm-s-tomorrow-night-eighties span.cm-number { color: #a16a94; } + +.cm-s-tomorrow-night-eighties span.cm-property, .cm-s-tomorrow-night-eighties span.cm-attribute { color: #99cc99; } +.cm-s-tomorrow-night-eighties span.cm-keyword { color: #f2777a; } +.cm-s-tomorrow-night-eighties span.cm-string { color: #ffcc66; } + +.cm-s-tomorrow-night-eighties span.cm-variable { color: #99cc99; } +.cm-s-tomorrow-night-eighties span.cm-variable-2 { color: #6699cc; } +.cm-s-tomorrow-night-eighties span.cm-def { color: #f99157; } +.cm-s-tomorrow-night-eighties span.cm-bracket { color: #CCCCCC; } +.cm-s-tomorrow-night-eighties span.cm-tag { color: #f2777a; } +.cm-s-tomorrow-night-eighties span.cm-link { color: #a16a94; } +.cm-s-tomorrow-night-eighties span.cm-error { background: #f2777a; color: #6A6A6A; } + +.cm-s-tomorrow-night-eighties .CodeMirror-activeline-background { background: #343600; } +.cm-s-tomorrow-night-eighties .CodeMirror-matchingbracket { text-decoration: underline; color: white !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/ttcn.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/ttcn.css new file mode 100644 index 000000000000..0b14ac35d64f --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/ttcn.css @@ -0,0 +1,64 @@ +.cm-s-ttcn .cm-quote { color: #090; } +.cm-s-ttcn .cm-negative { color: #d44; } +.cm-s-ttcn .cm-positive { color: #292; } +.cm-s-ttcn .cm-header, .cm-strong { font-weight: bold; } +.cm-s-ttcn .cm-em { font-style: italic; } +.cm-s-ttcn .cm-link { text-decoration: underline; } +.cm-s-ttcn .cm-strikethrough { text-decoration: line-through; } +.cm-s-ttcn .cm-header { color: #00f; font-weight: bold; } + +.cm-s-ttcn .cm-atom { color: #219; } +.cm-s-ttcn .cm-attribute { color: #00c; } +.cm-s-ttcn .cm-bracket { color: #997; } +.cm-s-ttcn .cm-comment { color: #333333; } +.cm-s-ttcn .cm-def { color: #00f; } +.cm-s-ttcn .cm-em { font-style: italic; } +.cm-s-ttcn .cm-error { color: #f00; } +.cm-s-ttcn .cm-hr { color: #999; } +.cm-s-ttcn .cm-invalidchar { color: #f00; } +.cm-s-ttcn .cm-keyword { font-weight:bold; } +.cm-s-ttcn .cm-link { color: #00c; text-decoration: underline; } +.cm-s-ttcn .cm-meta { color: #555; } +.cm-s-ttcn .cm-negative { color: #d44; } +.cm-s-ttcn .cm-positive { color: #292; } +.cm-s-ttcn .cm-qualifier { color: #555; } +.cm-s-ttcn .cm-strikethrough { text-decoration: line-through; } +.cm-s-ttcn .cm-string { color: #006400; } +.cm-s-ttcn .cm-string-2 { color: #f50; } +.cm-s-ttcn .cm-strong { font-weight: bold; } +.cm-s-ttcn .cm-tag { color: #170; } +.cm-s-ttcn .cm-variable { color: #8B2252; } +.cm-s-ttcn .cm-variable-2 { color: #05a; } +.cm-s-ttcn .cm-variable-3, .cm-s-ttcn .cm-type { color: #085; } + +.cm-s-ttcn .cm-invalidchar { color: #f00; } + +/* ASN */ +.cm-s-ttcn .cm-accessTypes, +.cm-s-ttcn .cm-compareTypes { color: #27408B; } +.cm-s-ttcn .cm-cmipVerbs { color: #8B2252; } +.cm-s-ttcn .cm-modifier { color:#D2691E; } +.cm-s-ttcn .cm-status { color:#8B4545; } +.cm-s-ttcn .cm-storage { color:#A020F0; } +.cm-s-ttcn .cm-tags { color:#006400; } + +/* CFG */ +.cm-s-ttcn .cm-externalCommands { color: #8B4545; font-weight:bold; } +.cm-s-ttcn .cm-fileNCtrlMaskOptions, +.cm-s-ttcn .cm-sectionTitle { color: #2E8B57; font-weight:bold; } + +/* TTCN */ +.cm-s-ttcn .cm-booleanConsts, +.cm-s-ttcn .cm-otherConsts, +.cm-s-ttcn .cm-verdictConsts { color: #006400; } +.cm-s-ttcn .cm-configOps, +.cm-s-ttcn .cm-functionOps, +.cm-s-ttcn .cm-portOps, +.cm-s-ttcn .cm-sutOps, +.cm-s-ttcn .cm-timerOps, +.cm-s-ttcn .cm-verdictOps { color: #0000FF; } +.cm-s-ttcn .cm-preprocessor, +.cm-s-ttcn .cm-templateMatch, +.cm-s-ttcn .cm-ttcn3Macros { color: #27408B; } +.cm-s-ttcn .cm-types { color: #A52A2A; font-weight:bold; } +.cm-s-ttcn .cm-visibilityModifiers { font-weight:bold; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/twilight.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/twilight.css new file mode 100644 index 000000000000..b2b1b2aa93e0 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/twilight.css @@ -0,0 +1,32 @@ +.cm-s-twilight.CodeMirror { background: #141414; color: #f7f7f7; } /**/ +.cm-s-twilight div.CodeMirror-selected { background: #323232; } /**/ +.cm-s-twilight .CodeMirror-line::selection, .cm-s-twilight .CodeMirror-line > span::selection, .cm-s-twilight .CodeMirror-line > span > span::selection { background: rgba(50, 50, 50, 0.99); } +.cm-s-twilight .CodeMirror-line::-moz-selection, .cm-s-twilight .CodeMirror-line > span::-moz-selection, .cm-s-twilight .CodeMirror-line > span > span::-moz-selection { background: rgba(50, 50, 50, 0.99); } + +.cm-s-twilight .CodeMirror-gutters { background: #222; border-right: 1px solid #aaa; } +.cm-s-twilight .CodeMirror-guttermarker { color: white; } +.cm-s-twilight .CodeMirror-guttermarker-subtle { color: #aaa; } +.cm-s-twilight .CodeMirror-linenumber { color: #aaa; } +.cm-s-twilight .CodeMirror-cursor { border-left: 1px solid white; } + +.cm-s-twilight .cm-keyword { color: #f9ee98; } /**/ +.cm-s-twilight .cm-atom { color: #FC0; } +.cm-s-twilight .cm-number { color: #ca7841; } /**/ +.cm-s-twilight .cm-def { color: #8DA6CE; } +.cm-s-twilight span.cm-variable-2, .cm-s-twilight span.cm-tag { color: #607392; } /**/ +.cm-s-twilight span.cm-variable-3, .cm-s-twilight span.cm-def, .cm-s-twilight span.cm-type { color: #607392; } /**/ +.cm-s-twilight .cm-operator { color: #cda869; } /**/ +.cm-s-twilight .cm-comment { color:#777; font-style:italic; font-weight:normal; } /**/ +.cm-s-twilight .cm-string { color:#8f9d6a; font-style:italic; } /**/ +.cm-s-twilight .cm-string-2 { color:#bd6b18; } /*?*/ +.cm-s-twilight .cm-meta { background-color:#141414; color:#f7f7f7; } /*?*/ +.cm-s-twilight .cm-builtin { color: #cda869; } /*?*/ +.cm-s-twilight .cm-tag { color: #997643; } /**/ +.cm-s-twilight .cm-attribute { color: #d6bb6d; } /*?*/ +.cm-s-twilight .cm-header { color: #FF6400; } +.cm-s-twilight .cm-hr { color: #AEAEAE; } +.cm-s-twilight .cm-link { color:#ad9361; font-style:italic; text-decoration:none; } /**/ +.cm-s-twilight .cm-error { border-bottom: 1px solid red; } + +.cm-s-twilight .CodeMirror-activeline-background { background: #27282E; } +.cm-s-twilight .CodeMirror-matchingbracket { outline:1px solid grey; color:white !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/vibrant-ink.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/vibrant-ink.css new file mode 100644 index 000000000000..6358ad3655a4 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/vibrant-ink.css @@ -0,0 +1,34 @@ +/* Taken from the popular Visual Studio Vibrant Ink Schema */ + +.cm-s-vibrant-ink.CodeMirror { background: black; color: white; } +.cm-s-vibrant-ink div.CodeMirror-selected { background: #35493c; } +.cm-s-vibrant-ink .CodeMirror-line::selection, .cm-s-vibrant-ink .CodeMirror-line > span::selection, .cm-s-vibrant-ink .CodeMirror-line > span > span::selection { background: rgba(53, 73, 60, 0.99); } +.cm-s-vibrant-ink .CodeMirror-line::-moz-selection, .cm-s-vibrant-ink .CodeMirror-line > span::-moz-selection, .cm-s-vibrant-ink .CodeMirror-line > span > span::-moz-selection { background: rgba(53, 73, 60, 0.99); } + +.cm-s-vibrant-ink .CodeMirror-gutters { background: #002240; border-right: 1px solid #aaa; } +.cm-s-vibrant-ink .CodeMirror-guttermarker { color: white; } +.cm-s-vibrant-ink .CodeMirror-guttermarker-subtle { color: #d0d0d0; } +.cm-s-vibrant-ink .CodeMirror-linenumber { color: #d0d0d0; } +.cm-s-vibrant-ink .CodeMirror-cursor { border-left: 1px solid white; } + +.cm-s-vibrant-ink .cm-keyword { color: #CC7832; } +.cm-s-vibrant-ink .cm-atom { color: #FC0; } +.cm-s-vibrant-ink .cm-number { color: #FFEE98; } +.cm-s-vibrant-ink .cm-def { color: #8DA6CE; } +.cm-s-vibrant-ink span.cm-variable-2, .cm-s-vibrant span.cm-tag { color: #FFC66D; } +.cm-s-vibrant-ink span.cm-variable-3, .cm-s-vibrant span.cm-def, .cm-s-vibrant span.cm-type { color: #FFC66D; } +.cm-s-vibrant-ink .cm-operator { color: #888; } +.cm-s-vibrant-ink .cm-comment { color: gray; font-weight: bold; } +.cm-s-vibrant-ink .cm-string { color: #A5C25C; } +.cm-s-vibrant-ink .cm-string-2 { color: red; } +.cm-s-vibrant-ink .cm-meta { color: #D8FA3C; } +.cm-s-vibrant-ink .cm-builtin { color: #8DA6CE; } +.cm-s-vibrant-ink .cm-tag { color: #8DA6CE; } +.cm-s-vibrant-ink .cm-attribute { color: #8DA6CE; } +.cm-s-vibrant-ink .cm-header { color: #FF6400; } +.cm-s-vibrant-ink .cm-hr { color: #AEAEAE; } +.cm-s-vibrant-ink .cm-link { color: #5656F3; } +.cm-s-vibrant-ink .cm-error { border-bottom: 1px solid red; } + +.cm-s-vibrant-ink .CodeMirror-activeline-background { background: #27282E; } +.cm-s-vibrant-ink .CodeMirror-matchingbracket { outline:1px solid grey; color:white !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/xq-dark.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/xq-dark.css new file mode 100644 index 000000000000..7da1a0f7053a --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/xq-dark.css @@ -0,0 +1,53 @@ +/* +Copyright (C) 2011 by MarkLogic Corporation +Author: Mike Brevoort + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +.cm-s-xq-dark.CodeMirror { background: #0a001f; color: #f8f8f8; } +.cm-s-xq-dark div.CodeMirror-selected { background: #27007A; } +.cm-s-xq-dark .CodeMirror-line::selection, .cm-s-xq-dark .CodeMirror-line > span::selection, .cm-s-xq-dark .CodeMirror-line > span > span::selection { background: rgba(39, 0, 122, 0.99); } +.cm-s-xq-dark .CodeMirror-line::-moz-selection, .cm-s-xq-dark .CodeMirror-line > span::-moz-selection, .cm-s-xq-dark .CodeMirror-line > span > span::-moz-selection { background: rgba(39, 0, 122, 0.99); } +.cm-s-xq-dark .CodeMirror-gutters { background: #0a001f; border-right: 1px solid #aaa; } +.cm-s-xq-dark .CodeMirror-guttermarker { color: #FFBD40; } +.cm-s-xq-dark .CodeMirror-guttermarker-subtle { color: #f8f8f8; } +.cm-s-xq-dark .CodeMirror-linenumber { color: #f8f8f8; } +.cm-s-xq-dark .CodeMirror-cursor { border-left: 1px solid white; } + +.cm-s-xq-dark span.cm-keyword { color: #FFBD40; } +.cm-s-xq-dark span.cm-atom { color: #6C8CD5; } +.cm-s-xq-dark span.cm-number { color: #164; } +.cm-s-xq-dark span.cm-def { color: #FFF; text-decoration:underline; } +.cm-s-xq-dark span.cm-variable { color: #FFF; } +.cm-s-xq-dark span.cm-variable-2 { color: #EEE; } +.cm-s-xq-dark span.cm-variable-3, .cm-s-xq-dark span.cm-type { color: #DDD; } +.cm-s-xq-dark span.cm-property {} +.cm-s-xq-dark span.cm-operator {} +.cm-s-xq-dark span.cm-comment { color: gray; } +.cm-s-xq-dark span.cm-string { color: #9FEE00; } +.cm-s-xq-dark span.cm-meta { color: yellow; } +.cm-s-xq-dark span.cm-qualifier { color: #FFF700; } +.cm-s-xq-dark span.cm-builtin { color: #30a; } +.cm-s-xq-dark span.cm-bracket { color: #cc7; } +.cm-s-xq-dark span.cm-tag { color: #FFBD40; } +.cm-s-xq-dark span.cm-attribute { color: #FFF700; } +.cm-s-xq-dark span.cm-error { color: #f00; } + +.cm-s-xq-dark .CodeMirror-activeline-background { background: #27282E; } +.cm-s-xq-dark .CodeMirror-matchingbracket { outline:1px solid grey; color:white !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/xq-light.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/xq-light.css new file mode 100644 index 000000000000..7b182ea99756 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/xq-light.css @@ -0,0 +1,43 @@ +/* +Copyright (C) 2011 by MarkLogic Corporation +Author: Mike Brevoort + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ +.cm-s-xq-light span.cm-keyword { line-height: 1em; font-weight: bold; color: #5A5CAD; } +.cm-s-xq-light span.cm-atom { color: #6C8CD5; } +.cm-s-xq-light span.cm-number { color: #164; } +.cm-s-xq-light span.cm-def { text-decoration:underline; } +.cm-s-xq-light span.cm-variable { color: black; } +.cm-s-xq-light span.cm-variable-2 { color:black; } +.cm-s-xq-light span.cm-variable-3, .cm-s-xq-light span.cm-type { color: black; } +.cm-s-xq-light span.cm-property {} +.cm-s-xq-light span.cm-operator {} +.cm-s-xq-light span.cm-comment { color: #0080FF; font-style: italic; } +.cm-s-xq-light span.cm-string { color: red; } +.cm-s-xq-light span.cm-meta { color: yellow; } +.cm-s-xq-light span.cm-qualifier { color: grey; } +.cm-s-xq-light span.cm-builtin { color: #7EA656; } +.cm-s-xq-light span.cm-bracket { color: #cc7; } +.cm-s-xq-light span.cm-tag { color: #3F7F7F; } +.cm-s-xq-light span.cm-attribute { color: #7F007F; } +.cm-s-xq-light span.cm-error { color: #f00; } + +.cm-s-xq-light .CodeMirror-activeline-background { background: #e8f2ff; } +.cm-s-xq-light .CodeMirror-matchingbracket { outline:1px solid grey;color:black !important;background:yellow; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/yeti.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/yeti.css new file mode 100644 index 000000000000..d085f7249715 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/yeti.css @@ -0,0 +1,44 @@ +/* + + Name: yeti + Author: Michael Kaminsky (http://github.com/mkaminsky11) + + Original yeti color scheme by Jesse Weed (https://github.com/jesseweed/yeti-syntax) + +*/ + + +.cm-s-yeti.CodeMirror { + background-color: #ECEAE8 !important; + color: #d1c9c0 !important; + border: none; +} + +.cm-s-yeti .CodeMirror-gutters { + color: #adaba6; + background-color: #E5E1DB; + border: none; +} +.cm-s-yeti .CodeMirror-cursor { border-left: solid thin #d1c9c0; } +.cm-s-yeti .CodeMirror-linenumber { color: #adaba6; } +.cm-s-yeti.CodeMirror-focused div.CodeMirror-selected { background: #DCD8D2; } +.cm-s-yeti .CodeMirror-line::selection, .cm-s-yeti .CodeMirror-line > span::selection, .cm-s-yeti .CodeMirror-line > span > span::selection { background: #DCD8D2; } +.cm-s-yeti .CodeMirror-line::-moz-selection, .cm-s-yeti .CodeMirror-line > span::-moz-selection, .cm-s-yeti .CodeMirror-line > span > span::-moz-selection { background: #DCD8D2; } +.cm-s-yeti span.cm-comment { color: #d4c8be; } +.cm-s-yeti span.cm-string, .cm-s-yeti span.cm-string-2 { color: #96c0d8; } +.cm-s-yeti span.cm-number { color: #a074c4; } +.cm-s-yeti span.cm-variable { color: #55b5db; } +.cm-s-yeti span.cm-variable-2 { color: #a074c4; } +.cm-s-yeti span.cm-def { color: #55b5db; } +.cm-s-yeti span.cm-operator { color: #9fb96e; } +.cm-s-yeti span.cm-keyword { color: #9fb96e; } +.cm-s-yeti span.cm-atom { color: #a074c4; } +.cm-s-yeti span.cm-meta { color: #96c0d8; } +.cm-s-yeti span.cm-tag { color: #96c0d8; } +.cm-s-yeti span.cm-attribute { color: #9fb96e; } +.cm-s-yeti span.cm-qualifier { color: #96c0d8; } +.cm-s-yeti span.cm-property { color: #a074c4; } +.cm-s-yeti span.cm-builtin { color: #a074c4; } +.cm-s-yeti span.cm-variable-3, .cm-s-yeti span.cm-type { color: #96c0d8; } +.cm-s-yeti .CodeMirror-activeline-background { background: #E7E4E0; } +.cm-s-yeti .CodeMirror-matchingbracket { text-decoration: underline; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/yonce.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/yonce.css new file mode 100644 index 000000000000..975f0788a2b5 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/yonce.css @@ -0,0 +1,59 @@ +/* + + Name: yoncé + Author: Thomas MacLean (http://github.com/thomasmaclean) + + Original yoncé color scheme by Mina Markham (https://github.com/minamarkham) + +*/ + +.cm-s-yonce.CodeMirror { background: #1C1C1C; color: #d4d4d4; } /**/ +.cm-s-yonce div.CodeMirror-selected { background: rgba(252, 69, 133, 0.478); } /**/ +.cm-s-yonce .CodeMirror-selectedtext, +.cm-s-yonce .CodeMirror-selected, +.cm-s-yonce .CodeMirror-line::selection, +.cm-s-yonce .CodeMirror-line > span::selection, +.cm-s-yonce .CodeMirror-line > span > span::selection, +.cm-s-yonce .CodeMirror-line::-moz-selection, +.cm-s-yonce .CodeMirror-line > span::-moz-selection, +.cm-s-yonce .CodeMirror-line > span > span::-moz-selection { background: rgba(252, 67, 132, 0.47); } + +.cm-s-yonce.CodeMirror pre { padding-left: 0px; } +.cm-s-yonce .CodeMirror-gutters {background: #1C1C1C; border-right: 0px;} +.cm-s-yonce .CodeMirror-linenumber {color: #777777; padding-right: 10px; } +.cm-s-yonce .CodeMirror-activeline .CodeMirror-linenumber.CodeMirror-gutter-elt { background: #1C1C1C; color: #fc4384; } +.cm-s-yonce .CodeMirror-linenumber { color: #777; } +.cm-s-yonce .CodeMirror-cursor { border-left: 2px solid #FC4384; } +.cm-s-yonce .cm-searching { background: rgba(243, 155, 53, .3) !important; outline: 1px solid #F39B35; } +.cm-s-yonce .cm-searching.CodeMirror-selectedtext { background: rgba(243, 155, 53, .7) !important; color: white; } + +.cm-s-yonce .cm-keyword { color: #00A7AA; } /**/ +.cm-s-yonce .cm-atom { color: #F39B35; } +.cm-s-yonce .cm-number, .cm-s-yonce span.cm-type { color: #A06FCA; } /**/ +.cm-s-yonce .cm-def { color: #98E342; } +.cm-s-yonce .cm-property, +.cm-s-yonce span.cm-variable { color: #D4D4D4; font-style: italic; } +.cm-s-yonce span.cm-variable-2 { color: #da7dae; font-style: italic; } +.cm-s-yonce span.cm-variable-3 { color: #A06FCA; } +.cm-s-yonce .cm-type.cm-def { color: #FC4384; font-style: normal; text-decoration: underline; } +.cm-s-yonce .cm-property.cm-def { color: #FC4384; font-style: normal; } +.cm-s-yonce .cm-callee { color: #FC4384; font-style: normal; } +.cm-s-yonce .cm-operator { color: #FC4384; } /**/ +.cm-s-yonce .cm-qualifier, +.cm-s-yonce .cm-tag { color: #FC4384; } +.cm-s-yonce .cm-tag.cm-bracket { color: #D4D4D4; } +.cm-s-yonce .cm-attribute { color: #A06FCA; } +.cm-s-yonce .cm-comment { color:#696d70; font-style:italic; font-weight:normal; } /**/ +.cm-s-yonce .cm-comment.cm-tag { color: #FC4384 } +.cm-s-yonce .cm-comment.cm-attribute { color: #D4D4D4; } +.cm-s-yonce .cm-string { color:#E6DB74; } /**/ +.cm-s-yonce .cm-string-2 { color:#F39B35; } /*?*/ +.cm-s-yonce .cm-meta { color: #D4D4D4; background: inherit; } +.cm-s-yonce .cm-builtin { color: #FC4384; } /*?*/ +.cm-s-yonce .cm-header { color: #da7dae; } +.cm-s-yonce .cm-hr { color: #98E342; } +.cm-s-yonce .cm-link { color:#696d70; font-style:italic; text-decoration:none; } /**/ +.cm-s-yonce .cm-error { border-bottom: 1px solid #C42412; } + +.cm-s-yonce .CodeMirror-activeline-background { background: #272727; } +.cm-s-yonce .CodeMirror-matchingbracket { outline:1px solid grey; color:#D4D4D4 !important; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror-theme/zenburn.css b/waterfox/browser/components/sidebar/extlib/codemirror-theme/zenburn.css new file mode 100644 index 000000000000..4eb4247c9285 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror-theme/zenburn.css @@ -0,0 +1,37 @@ +/** + * " + * Using Zenburn color palette from the Emacs Zenburn Theme + * https://github.com/bbatsov/zenburn-emacs/blob/master/zenburn-theme.el + * + * Also using parts of https://github.com/xavi/coderay-lighttable-theme + * " + * From: https://github.com/wisenomad/zenburn-lighttable-theme/blob/master/zenburn.css + */ + +.cm-s-zenburn .CodeMirror-gutters { background: #3f3f3f !important; } +.cm-s-zenburn .CodeMirror-foldgutter-open, .CodeMirror-foldgutter-folded { color: #999; } +.cm-s-zenburn .CodeMirror-cursor { border-left: 1px solid white; } +.cm-s-zenburn.CodeMirror { background-color: #3f3f3f; color: #dcdccc; } +.cm-s-zenburn span.cm-builtin { color: #dcdccc; font-weight: bold; } +.cm-s-zenburn span.cm-comment { color: #7f9f7f; } +.cm-s-zenburn span.cm-keyword { color: #f0dfaf; font-weight: bold; } +.cm-s-zenburn span.cm-atom { color: #bfebbf; } +.cm-s-zenburn span.cm-def { color: #dcdccc; } +.cm-s-zenburn span.cm-variable { color: #dfaf8f; } +.cm-s-zenburn span.cm-variable-2 { color: #dcdccc; } +.cm-s-zenburn span.cm-string { color: #cc9393; } +.cm-s-zenburn span.cm-string-2 { color: #cc9393; } +.cm-s-zenburn span.cm-number { color: #dcdccc; } +.cm-s-zenburn span.cm-tag { color: #93e0e3; } +.cm-s-zenburn span.cm-property { color: #dfaf8f; } +.cm-s-zenburn span.cm-attribute { color: #dfaf8f; } +.cm-s-zenburn span.cm-qualifier { color: #7cb8bb; } +.cm-s-zenburn span.cm-meta { color: #f0dfaf; } +.cm-s-zenburn span.cm-header { color: #f0efd0; } +.cm-s-zenburn span.cm-operator { color: #f0efd0; } +.cm-s-zenburn span.CodeMirror-matchingbracket { box-sizing: border-box; background: transparent; border-bottom: 1px solid; } +.cm-s-zenburn span.CodeMirror-nonmatchingbracket { border-bottom: 1px solid; background: none; } +.cm-s-zenburn .CodeMirror-activeline { background: #000000; } +.cm-s-zenburn .CodeMirror-activeline-background { background: #000000; } +.cm-s-zenburn div.CodeMirror-selected { background: #545454; } +.cm-s-zenburn .CodeMirror-focused div.CodeMirror-selected { background: #4f4f4f; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror.css b/waterfox/browser/components/sidebar/extlib/codemirror.css new file mode 100644 index 000000000000..dbe6664ee2a8 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror.css @@ -0,0 +1,345 @@ +/* CodeMirror version 5.65.19 */ +/* BASICS */ + +.CodeMirror { + /* Set height, width, borders, and global font properties here */ + font-family: monospace; + height: 300px; + color: black; + direction: ltr; +} + +/* PADDING */ + +.CodeMirror-lines { + padding: 4px 0; /* Vertical padding around content */ +} +.CodeMirror pre.CodeMirror-line, +.CodeMirror pre.CodeMirror-line-like { + padding: 0 4px; /* Horizontal padding of content */ +} + +.CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler { + background-color: white; /* The little square between H and V scrollbars */ +} + +/* GUTTER */ + +.CodeMirror-gutters { + border-right: 1px solid #ddd; + background-color: #f7f7f7; + white-space: nowrap; +} +.CodeMirror-linenumbers {} +.CodeMirror-linenumber { + padding: 0 3px 0 5px; + min-width: 20px; + text-align: right; + color: #999; + white-space: nowrap; +} + +.CodeMirror-guttermarker { color: black; } +.CodeMirror-guttermarker-subtle { color: #999; } + +/* CURSOR */ + +.CodeMirror-cursor { + border-left: 1px solid black; + border-right: none; + width: 0; +} +/* Shown when moving in bi-directional text */ +.CodeMirror div.CodeMirror-secondarycursor { + border-left: 1px solid silver; +} +.cm-fat-cursor .CodeMirror-cursor { + width: auto; + border: 0 !important; + background: #7e7; +} +.cm-fat-cursor div.CodeMirror-cursors { + z-index: 1; +} +.cm-fat-cursor .CodeMirror-line::selection, +.cm-fat-cursor .CodeMirror-line > span::selection, +.cm-fat-cursor .CodeMirror-line > span > span::selection { background: transparent; } +.cm-fat-cursor .CodeMirror-line::-moz-selection, +.cm-fat-cursor .CodeMirror-line > span::-moz-selection, +.cm-fat-cursor .CodeMirror-line > span > span::-moz-selection { background: transparent; } +.cm-fat-cursor { caret-color: transparent; } +@-moz-keyframes blink { + 0% {} + 50% { background-color: transparent; } + 100% {} +} +@-webkit-keyframes blink { + 0% {} + 50% { background-color: transparent; } + 100% {} +} +@keyframes blink { + 0% {} + 50% { background-color: transparent; } + 100% {} +} + +/* Can style cursor different in overwrite (non-insert) mode */ +.CodeMirror-overwrite .CodeMirror-cursor {} + +.cm-tab { display: inline-block; text-decoration: inherit; } + +.CodeMirror-rulers { + position: absolute; + left: 0; right: 0; top: -50px; bottom: 0; + overflow: hidden; +} +.CodeMirror-ruler { + border-left: 1px solid #ccc; + top: 0; bottom: 0; + position: absolute; +} + +/* DEFAULT THEME */ + +.cm-s-default .cm-header {color: blue;} +.cm-s-default .cm-quote {color: #090;} +.cm-negative {color: #d44;} +.cm-positive {color: #292;} +.cm-header, .cm-strong {font-weight: bold;} +.cm-em {font-style: italic;} +.cm-link {text-decoration: underline;} +.cm-strikethrough {text-decoration: line-through;} + +.cm-s-default .cm-keyword {color: #708;} +.cm-s-default .cm-atom {color: #219;} +.cm-s-default .cm-number {color: #164;} +.cm-s-default .cm-def {color: #00f;} +.cm-s-default .cm-variable, +.cm-s-default .cm-punctuation, +.cm-s-default .cm-property, +.cm-s-default .cm-operator {} +.cm-s-default .cm-variable-2 {color: #05a;} +.cm-s-default .cm-variable-3, .cm-s-default .cm-type {color: #085;} +.cm-s-default .cm-comment {color: #a50;} +.cm-s-default .cm-string {color: #a11;} +.cm-s-default .cm-string-2 {color: #f50;} +.cm-s-default .cm-meta {color: #555;} +.cm-s-default .cm-qualifier {color: #555;} +.cm-s-default .cm-builtin {color: #30a;} +.cm-s-default .cm-bracket {color: #997;} +.cm-s-default .cm-tag {color: #170;} +.cm-s-default .cm-attribute {color: #00c;} +.cm-s-default .cm-hr {color: #999;} +.cm-s-default .cm-link {color: #00c;} + +.cm-s-default .cm-error {color: #f00;} +.cm-invalidchar {color: #f00;} + +.CodeMirror-composing { border-bottom: 2px solid; } + +/* Default styles for common addons */ + +div.CodeMirror span.CodeMirror-matchingbracket {color: #0b0;} +div.CodeMirror span.CodeMirror-nonmatchingbracket {color: #a22;} +.CodeMirror-matchingtag { background: rgba(255, 150, 0, .3); } +.CodeMirror-activeline-background {background: #e8f2ff;} + +/* STOP */ + +/* The rest of this file contains styles related to the mechanics of + the editor. You probably shouldn't touch them. */ + +.CodeMirror { + position: relative; + overflow: hidden; + background: white; +} + +.CodeMirror-scroll { + overflow: scroll !important; /* Things will break if this is overridden */ + /* 50px is the magic margin used to hide the element's real scrollbars */ + /* See overflow: hidden in .CodeMirror */ + margin-bottom: -50px; margin-right: -50px; + padding-bottom: 50px; + height: 100%; + outline: none; /* Prevent dragging from highlighting the element */ + position: relative; + z-index: 0; +} +.CodeMirror-sizer { + position: relative; + border-right: 50px solid transparent; +} + +/* The fake, visible scrollbars. Used to force redraw during scrolling + before actual scrolling happens, thus preventing shaking and + flickering artifacts. */ +.CodeMirror-vscrollbar, .CodeMirror-hscrollbar, .CodeMirror-scrollbar-filler, .CodeMirror-gutter-filler { + position: absolute; + z-index: 6; + display: none; + outline: none; +} +.CodeMirror-vscrollbar { + right: 0; top: 0; + overflow-x: hidden; + overflow-y: scroll; +} +.CodeMirror-hscrollbar { + bottom: 0; left: 0; + overflow-y: hidden; + overflow-x: scroll; +} +.CodeMirror-scrollbar-filler { + right: 0; bottom: 0; +} +.CodeMirror-gutter-filler { + left: 0; bottom: 0; +} + +.CodeMirror-gutters { + position: absolute; left: 0; top: 0; + min-height: 100%; + z-index: 3; +} +.CodeMirror-gutter { + white-space: normal; + height: 100%; + display: inline-block; + vertical-align: top; + margin-bottom: -50px; +} +.CodeMirror-gutter-wrapper { + position: absolute; + z-index: 4; + background: none !important; + border: none !important; +} +.CodeMirror-gutter-background { + position: absolute; + top: 0; bottom: 0; + z-index: 4; +} +.CodeMirror-gutter-elt { + position: absolute; + cursor: default; + z-index: 4; +} +.CodeMirror-gutter-wrapper ::selection { background-color: transparent } +.CodeMirror-gutter-wrapper ::-moz-selection { background-color: transparent } + +.CodeMirror-lines { + cursor: text; + min-height: 1px; /* prevents collapsing before first draw */ +} +.CodeMirror pre.CodeMirror-line, +.CodeMirror pre.CodeMirror-line-like { + /* Reset some styles that the rest of the page might have set */ + -moz-border-radius: 0; -webkit-border-radius: 0; border-radius: 0; + border-width: 0; + background: transparent; + font-family: inherit; + font-size: inherit; + margin: 0; + white-space: pre; + word-wrap: normal; + line-height: inherit; + color: inherit; + z-index: 2; + position: relative; + overflow: visible; + -webkit-tap-highlight-color: transparent; + -webkit-font-variant-ligatures: contextual; + font-variant-ligatures: contextual; +} +.CodeMirror-wrap pre.CodeMirror-line, +.CodeMirror-wrap pre.CodeMirror-line-like { + word-wrap: break-word; + white-space: pre-wrap; + word-break: normal; +} + +.CodeMirror-linebackground { + position: absolute; + left: 0; right: 0; top: 0; bottom: 0; + z-index: 0; +} + +.CodeMirror-linewidget { + position: relative; + z-index: 2; + padding: 0.1px; /* Force widget margins to stay inside of the container */ +} + +.CodeMirror-widget {} + +.CodeMirror-rtl pre { direction: rtl; } + +.CodeMirror-code { + outline: none; +} + +/* Force content-box sizing for the elements where we expect it */ +.CodeMirror-scroll, +.CodeMirror-sizer, +.CodeMirror-gutter, +.CodeMirror-gutters, +.CodeMirror-linenumber { + -moz-box-sizing: content-box; + box-sizing: content-box; +} + +.CodeMirror-measure { + position: absolute; + width: 100%; + height: 0; + overflow: hidden; + visibility: hidden; +} + +.CodeMirror-cursor { + position: absolute; + pointer-events: none; +} +.CodeMirror-measure pre { position: static; } + +div.CodeMirror-cursors { + visibility: hidden; + position: relative; + z-index: 3; +} +div.CodeMirror-dragcursors { + visibility: visible; +} + +.CodeMirror-focused div.CodeMirror-cursors { + visibility: visible; +} + +.CodeMirror-selected { background: #d9d9d9; } +.CodeMirror-focused .CodeMirror-selected { background: #d7d4f0; } +.CodeMirror-crosshair { cursor: crosshair; } +.CodeMirror-line::selection, .CodeMirror-line > span::selection, .CodeMirror-line > span > span::selection { background: #d7d4f0; } +.CodeMirror-line::-moz-selection, .CodeMirror-line > span::-moz-selection, .CodeMirror-line > span > span::-moz-selection { background: #d7d4f0; } + +.cm-searching { + background-color: #ffa; + background-color: rgba(255, 255, 0, .4); +} + +/* Used to force a border model for a node */ +.cm-force-border { padding-right: .1px; } + +@media print { + /* Hide the cursor when printing */ + .CodeMirror div.CodeMirror-cursors { + visibility: hidden; + } +} + +/* See issue #2901 */ +.cm-tab-wrap-hack:after { content: ''; } + +/* Help users use markselection to safely style text background */ +span.CodeMirror-selectedtext { background: none; } diff --git a/waterfox/browser/components/sidebar/extlib/codemirror.js b/waterfox/browser/components/sidebar/extlib/codemirror.js new file mode 100644 index 000000000000..6a5535eb3047 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/codemirror.js @@ -0,0 +1,9886 @@ +/* CodeMirror version 5.65.19 */ +// CodeMirror, copyright (c) by Marijn Haverbeke and others +// Distributed under an MIT license: https://codemirror.net/5/LICENSE + +// This is CodeMirror (https://codemirror.net/5), a code editor +// implemented in JavaScript on top of the browser's DOM. +// +// You can find some technical background for some of the code below +// at http://marijnhaverbeke.nl/blog/#cm-internals . + +(function (global, factory) { + typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory() : + typeof define === 'function' && define.amd ? define(factory) : + (global = global || self, global.CodeMirror = factory()); +}(this, (function () { 'use strict'; + + // Kludges for bugs and behavior differences that can't be feature + // detected are enabled based on userAgent etc sniffing. + var userAgent = navigator.userAgent; + var platform = navigator.platform; + + var gecko = /gecko\/\d/i.test(userAgent); + var ie_upto10 = /MSIE \d/.test(userAgent); + var ie_11up = /Trident\/(?:[7-9]|\d{2,})\..*rv:(\d+)/.exec(userAgent); + var edge = /Edge\/(\d+)/.exec(userAgent); + var ie = ie_upto10 || ie_11up || edge; + var ie_version = ie && (ie_upto10 ? document.documentMode || 6 : +(edge || ie_11up)[1]); + var webkit = !edge && /WebKit\//.test(userAgent); + var qtwebkit = webkit && /Qt\/\d+\.\d+/.test(userAgent); + var chrome = !edge && /Chrome\/(\d+)/.exec(userAgent); + var chrome_version = chrome && +chrome[1]; + var presto = /Opera\//.test(userAgent); + var safari = /Apple Computer/.test(navigator.vendor); + var mac_geMountainLion = /Mac OS X 1\d\D([8-9]|\d\d)\D/.test(userAgent); + var phantom = /PhantomJS/.test(userAgent); + + var ios = safari && (/Mobile\/\w+/.test(userAgent) || navigator.maxTouchPoints > 2); + var android = /Android/.test(userAgent); + // This is woefully incomplete. Suggestions for alternative methods welcome. + var mobile = ios || android || /webOS|BlackBerry|Opera Mini|Opera Mobi|IEMobile/i.test(userAgent); + var mac = ios || /Mac/.test(platform); + var chromeOS = /\bCrOS\b/.test(userAgent); + var windows = /win/i.test(platform); + + var presto_version = presto && userAgent.match(/Version\/(\d*\.\d*)/); + if (presto_version) { presto_version = Number(presto_version[1]); } + if (presto_version && presto_version >= 15) { presto = false; webkit = true; } + // Some browsers use the wrong event properties to signal cmd/ctrl on OS X + var flipCtrlCmd = mac && (qtwebkit || presto && (presto_version == null || presto_version < 12.11)); + var captureRightClick = gecko || (ie && ie_version >= 9); + + function classTest(cls) { return new RegExp("(^|\\s)" + cls + "(?:$|\\s)\\s*") } + + var rmClass = function(node, cls) { + var current = node.className; + var match = classTest(cls).exec(current); + if (match) { + var after = current.slice(match.index + match[0].length); + node.className = current.slice(0, match.index) + (after ? match[1] + after : ""); + } + }; + + function removeChildren(e) { + for (var count = e.childNodes.length; count > 0; --count) + { e.removeChild(e.firstChild); } + return e + } + + function removeChildrenAndAdd(parent, e) { + return removeChildren(parent).appendChild(e) + } + + function elt(tag, content, className, style) { + var e = document.createElement(tag); + if (className) { e.className = className; } + if (style) { e.style.cssText = style; } + if (typeof content == "string") { e.appendChild(document.createTextNode(content)); } + else if (content) { for (var i = 0; i < content.length; ++i) { e.appendChild(content[i]); } } + return e + } + // wrapper for elt, which removes the elt from the accessibility tree + function eltP(tag, content, className, style) { + var e = elt(tag, content, className, style); + e.setAttribute("role", "presentation"); + return e + } + + var range; + if (document.createRange) { range = function(node, start, end, endNode) { + var r = document.createRange(); + r.setEnd(endNode || node, end); + r.setStart(node, start); + return r + }; } + else { range = function(node, start, end) { + var r = document.body.createTextRange(); + try { r.moveToElementText(node.parentNode); } + catch(e) { return r } + r.collapse(true); + r.moveEnd("character", end); + r.moveStart("character", start); + return r + }; } + + function contains(parent, child) { + if (child.nodeType == 3) // Android browser always returns false when child is a textnode + { child = child.parentNode; } + if (parent.contains) + { return parent.contains(child) } + do { + if (child.nodeType == 11) { child = child.host; } + if (child == parent) { return true } + } while (child = child.parentNode) + } + + function activeElt(rootNode) { + // IE and Edge may throw an "Unspecified Error" when accessing document.activeElement. + // IE < 10 will throw when accessed while the page is loading or in an iframe. + // IE > 9 and Edge will throw when accessed in an iframe if document.body is unavailable. + var doc = rootNode.ownerDocument || rootNode; + var activeElement; + try { + activeElement = rootNode.activeElement; + } catch(e) { + activeElement = doc.body || null; + } + while (activeElement && activeElement.shadowRoot && activeElement.shadowRoot.activeElement) + { activeElement = activeElement.shadowRoot.activeElement; } + return activeElement + } + + function addClass(node, cls) { + var current = node.className; + if (!classTest(cls).test(current)) { node.className += (current ? " " : "") + cls; } + } + function joinClasses(a, b) { + var as = a.split(" "); + for (var i = 0; i < as.length; i++) + { if (as[i] && !classTest(as[i]).test(b)) { b += " " + as[i]; } } + return b + } + + var selectInput = function(node) { node.select(); }; + if (ios) // Mobile Safari apparently has a bug where select() is broken. + { selectInput = function(node) { node.selectionStart = 0; node.selectionEnd = node.value.length; }; } + else if (ie) // Suppress mysterious IE10 errors + { selectInput = function(node) { try { node.select(); } catch(_e) {} }; } + + function doc(cm) { return cm.display.wrapper.ownerDocument } + + function root(cm) { + return rootNode(cm.display.wrapper) + } + + function rootNode(element) { + // Detect modern browsers (2017+). + return element.getRootNode ? element.getRootNode() : element.ownerDocument + } + + function win(cm) { return doc(cm).defaultView } + + function bind(f) { + var args = Array.prototype.slice.call(arguments, 1); + return function(){return f.apply(null, args)} + } + + function copyObj(obj, target, overwrite) { + if (!target) { target = {}; } + for (var prop in obj) + { if (obj.hasOwnProperty(prop) && (overwrite !== false || !target.hasOwnProperty(prop))) + { target[prop] = obj[prop]; } } + return target + } + + // Counts the column offset in a string, taking tabs into account. + // Used mostly to find indentation. + function countColumn(string, end, tabSize, startIndex, startValue) { + if (end == null) { + end = string.search(/[^\s\u00a0]/); + if (end == -1) { end = string.length; } + } + for (var i = startIndex || 0, n = startValue || 0;;) { + var nextTab = string.indexOf("\t", i); + if (nextTab < 0 || nextTab >= end) + { return n + (end - i) } + n += nextTab - i; + n += tabSize - (n % tabSize); + i = nextTab + 1; + } + } + + var Delayed = function() { + this.id = null; + this.f = null; + this.time = 0; + this.handler = bind(this.onTimeout, this); + }; + Delayed.prototype.onTimeout = function (self) { + self.id = 0; + if (self.time <= +new Date) { + self.f(); + } else { + setTimeout(self.handler, self.time - +new Date); + } + }; + Delayed.prototype.set = function (ms, f) { + this.f = f; + var time = +new Date + ms; + if (!this.id || time < this.time) { + clearTimeout(this.id); + this.id = setTimeout(this.handler, ms); + this.time = time; + } + }; + + function indexOf(array, elt) { + for (var i = 0; i < array.length; ++i) + { if (array[i] == elt) { return i } } + return -1 + } + + // Number of pixels added to scroller and sizer to hide scrollbar + var scrollerGap = 50; + + // Returned or thrown by various protocols to signal 'I'm not + // handling this'. + var Pass = {toString: function(){return "CodeMirror.Pass"}}; + + // Reused option objects for setSelection & friends + var sel_dontScroll = {scroll: false}, sel_mouse = {origin: "*mouse"}, sel_move = {origin: "+move"}; + + // The inverse of countColumn -- find the offset that corresponds to + // a particular column. + function findColumn(string, goal, tabSize) { + for (var pos = 0, col = 0;;) { + var nextTab = string.indexOf("\t", pos); + if (nextTab == -1) { nextTab = string.length; } + var skipped = nextTab - pos; + if (nextTab == string.length || col + skipped >= goal) + { return pos + Math.min(skipped, goal - col) } + col += nextTab - pos; + col += tabSize - (col % tabSize); + pos = nextTab + 1; + if (col >= goal) { return pos } + } + } + + var spaceStrs = [""]; + function spaceStr(n) { + while (spaceStrs.length <= n) + { spaceStrs.push(lst(spaceStrs) + " "); } + return spaceStrs[n] + } + + function lst(arr) { return arr[arr.length-1] } + + function map(array, f) { + var out = []; + for (var i = 0; i < array.length; i++) { out[i] = f(array[i], i); } + return out + } + + function insertSorted(array, value, score) { + var pos = 0, priority = score(value); + while (pos < array.length && score(array[pos]) <= priority) { pos++; } + array.splice(pos, 0, value); + } + + function nothing() {} + + function createObj(base, props) { + var inst; + if (Object.create) { + inst = Object.create(base); + } else { + nothing.prototype = base; + inst = new nothing(); + } + if (props) { copyObj(props, inst); } + return inst + } + + var nonASCIISingleCaseWordChar = /[\u00df\u0587\u0590-\u05f4\u0600-\u06ff\u3040-\u309f\u30a0-\u30ff\u3400-\u4db5\u4e00-\u9fcc\uac00-\ud7af]/; + function isWordCharBasic(ch) { + return /\w/.test(ch) || ch > "\x80" && + (ch.toUpperCase() != ch.toLowerCase() || nonASCIISingleCaseWordChar.test(ch)) + } + function isWordChar(ch, helper) { + if (!helper) { return isWordCharBasic(ch) } + if (helper.source.indexOf("\\w") > -1 && isWordCharBasic(ch)) { return true } + return helper.test(ch) + } + + function isEmpty(obj) { + for (var n in obj) { if (obj.hasOwnProperty(n) && obj[n]) { return false } } + return true + } + + // Extending unicode characters. A series of a non-extending char + + // any number of extending chars is treated as a single unit as far + // as editing and measuring is concerned. This is not fully correct, + // since some scripts/fonts/browsers also treat other configurations + // of code points as a group. + var extendingChars = /[\u0300-\u036f\u0483-\u0489\u0591-\u05bd\u05bf\u05c1\u05c2\u05c4\u05c5\u05c7\u0610-\u061a\u064b-\u065e\u0670\u06d6-\u06dc\u06de-\u06e4\u06e7\u06e8\u06ea-\u06ed\u0711\u0730-\u074a\u07a6-\u07b0\u07eb-\u07f3\u0816-\u0819\u081b-\u0823\u0825-\u0827\u0829-\u082d\u0900-\u0902\u093c\u0941-\u0948\u094d\u0951-\u0955\u0962\u0963\u0981\u09bc\u09be\u09c1-\u09c4\u09cd\u09d7\u09e2\u09e3\u0a01\u0a02\u0a3c\u0a41\u0a42\u0a47\u0a48\u0a4b-\u0a4d\u0a51\u0a70\u0a71\u0a75\u0a81\u0a82\u0abc\u0ac1-\u0ac5\u0ac7\u0ac8\u0acd\u0ae2\u0ae3\u0b01\u0b3c\u0b3e\u0b3f\u0b41-\u0b44\u0b4d\u0b56\u0b57\u0b62\u0b63\u0b82\u0bbe\u0bc0\u0bcd\u0bd7\u0c3e-\u0c40\u0c46-\u0c48\u0c4a-\u0c4d\u0c55\u0c56\u0c62\u0c63\u0cbc\u0cbf\u0cc2\u0cc6\u0ccc\u0ccd\u0cd5\u0cd6\u0ce2\u0ce3\u0d3e\u0d41-\u0d44\u0d4d\u0d57\u0d62\u0d63\u0dca\u0dcf\u0dd2-\u0dd4\u0dd6\u0ddf\u0e31\u0e34-\u0e3a\u0e47-\u0e4e\u0eb1\u0eb4-\u0eb9\u0ebb\u0ebc\u0ec8-\u0ecd\u0f18\u0f19\u0f35\u0f37\u0f39\u0f71-\u0f7e\u0f80-\u0f84\u0f86\u0f87\u0f90-\u0f97\u0f99-\u0fbc\u0fc6\u102d-\u1030\u1032-\u1037\u1039\u103a\u103d\u103e\u1058\u1059\u105e-\u1060\u1071-\u1074\u1082\u1085\u1086\u108d\u109d\u135f\u1712-\u1714\u1732-\u1734\u1752\u1753\u1772\u1773\u17b7-\u17bd\u17c6\u17c9-\u17d3\u17dd\u180b-\u180d\u18a9\u1920-\u1922\u1927\u1928\u1932\u1939-\u193b\u1a17\u1a18\u1a56\u1a58-\u1a5e\u1a60\u1a62\u1a65-\u1a6c\u1a73-\u1a7c\u1a7f\u1b00-\u1b03\u1b34\u1b36-\u1b3a\u1b3c\u1b42\u1b6b-\u1b73\u1b80\u1b81\u1ba2-\u1ba5\u1ba8\u1ba9\u1c2c-\u1c33\u1c36\u1c37\u1cd0-\u1cd2\u1cd4-\u1ce0\u1ce2-\u1ce8\u1ced\u1dc0-\u1de6\u1dfd-\u1dff\u200c\u200d\u20d0-\u20f0\u2cef-\u2cf1\u2de0-\u2dff\u302a-\u302f\u3099\u309a\ua66f-\ua672\ua67c\ua67d\ua6f0\ua6f1\ua802\ua806\ua80b\ua825\ua826\ua8c4\ua8e0-\ua8f1\ua926-\ua92d\ua947-\ua951\ua980-\ua982\ua9b3\ua9b6-\ua9b9\ua9bc\uaa29-\uaa2e\uaa31\uaa32\uaa35\uaa36\uaa43\uaa4c\uaab0\uaab2-\uaab4\uaab7\uaab8\uaabe\uaabf\uaac1\uabe5\uabe8\uabed\udc00-\udfff\ufb1e\ufe00-\ufe0f\ufe20-\ufe26\uff9e\uff9f]/; + function isExtendingChar(ch) { return ch.charCodeAt(0) >= 768 && extendingChars.test(ch) } + + // Returns a number from the range [`0`; `str.length`] unless `pos` is outside that range. + function skipExtendingChars(str, pos, dir) { + while ((dir < 0 ? pos > 0 : pos < str.length) && isExtendingChar(str.charAt(pos))) { pos += dir; } + return pos + } + + // Returns the value from the range [`from`; `to`] that satisfies + // `pred` and is closest to `from`. Assumes that at least `to` + // satisfies `pred`. Supports `from` being greater than `to`. + function findFirst(pred, from, to) { + // At any point we are certain `to` satisfies `pred`, don't know + // whether `from` does. + var dir = from > to ? -1 : 1; + for (;;) { + if (from == to) { return from } + var midF = (from + to) / 2, mid = dir < 0 ? Math.ceil(midF) : Math.floor(midF); + if (mid == from) { return pred(mid) ? from : to } + if (pred(mid)) { to = mid; } + else { from = mid + dir; } + } + } + + // BIDI HELPERS + + function iterateBidiSections(order, from, to, f) { + if (!order) { return f(from, to, "ltr", 0) } + var found = false; + for (var i = 0; i < order.length; ++i) { + var part = order[i]; + if (part.from < to && part.to > from || from == to && part.to == from) { + f(Math.max(part.from, from), Math.min(part.to, to), part.level == 1 ? "rtl" : "ltr", i); + found = true; + } + } + if (!found) { f(from, to, "ltr"); } + } + + var bidiOther = null; + function getBidiPartAt(order, ch, sticky) { + var found; + bidiOther = null; + for (var i = 0; i < order.length; ++i) { + var cur = order[i]; + if (cur.from < ch && cur.to > ch) { return i } + if (cur.to == ch) { + if (cur.from != cur.to && sticky == "before") { found = i; } + else { bidiOther = i; } + } + if (cur.from == ch) { + if (cur.from != cur.to && sticky != "before") { found = i; } + else { bidiOther = i; } + } + } + return found != null ? found : bidiOther + } + + // Bidirectional ordering algorithm + // See http://unicode.org/reports/tr9/tr9-13.html for the algorithm + // that this (partially) implements. + + // One-char codes used for character types: + // L (L): Left-to-Right + // R (R): Right-to-Left + // r (AL): Right-to-Left Arabic + // 1 (EN): European Number + // + (ES): European Number Separator + // % (ET): European Number Terminator + // n (AN): Arabic Number + // , (CS): Common Number Separator + // m (NSM): Non-Spacing Mark + // b (BN): Boundary Neutral + // s (B): Paragraph Separator + // t (S): Segment Separator + // w (WS): Whitespace + // N (ON): Other Neutrals + + // Returns null if characters are ordered as they appear + // (left-to-right), or an array of sections ({from, to, level} + // objects) in the order in which they occur visually. + var bidiOrdering = (function() { + // Character types for codepoints 0 to 0xff + var lowTypes = "bbbbbbbbbtstwsbbbbbbbbbbbbbbssstwNN%%%NNNNNN,N,N1111111111NNNNNNNLLLLLLLLLLLLLLLLLLLLLLLLLLNNNNNNLLLLLLLLLLLLLLLLLLLLLLLLLLNNNNbbbbbbsbbbbbbbbbbbbbbbbbbbbbbbbbb,N%%%%NNNNLNNNNN%%11NLNNN1LNNNNNLLLLLLLLLLLLLLLLLLLLLLLNLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLN"; + // Character types for codepoints 0x600 to 0x6f9 + var arabicTypes = "nnnnnnNNr%%r,rNNmmmmmmmmmmmrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrmmmmmmmmmmmmmmmmmmmmmnnnnnnnnnn%nnrrrmrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrrmmmmmmmnNmmmmmmrrmmNmmmmrr1111111111"; + function charType(code) { + if (code <= 0xf7) { return lowTypes.charAt(code) } + else if (0x590 <= code && code <= 0x5f4) { return "R" } + else if (0x600 <= code && code <= 0x6f9) { return arabicTypes.charAt(code - 0x600) } + else if (0x6ee <= code && code <= 0x8ac) { return "r" } + else if (0x2000 <= code && code <= 0x200b) { return "w" } + else if (code == 0x200c) { return "b" } + else { return "L" } + } + + var bidiRE = /[\u0590-\u05f4\u0600-\u06ff\u0700-\u08ac]/; + var isNeutral = /[stwN]/, isStrong = /[LRr]/, countsAsLeft = /[Lb1n]/, countsAsNum = /[1n]/; + + function BidiSpan(level, from, to) { + this.level = level; + this.from = from; this.to = to; + } + + return function(str, direction) { + var outerType = direction == "ltr" ? "L" : "R"; + + if (str.length == 0 || direction == "ltr" && !bidiRE.test(str)) { return false } + var len = str.length, types = []; + for (var i = 0; i < len; ++i) + { types.push(charType(str.charCodeAt(i))); } + + // W1. Examine each non-spacing mark (NSM) in the level run, and + // change the type of the NSM to the type of the previous + // character. If the NSM is at the start of the level run, it will + // get the type of sor. + for (var i$1 = 0, prev = outerType; i$1 < len; ++i$1) { + var type = types[i$1]; + if (type == "m") { types[i$1] = prev; } + else { prev = type; } + } + + // W2. Search backwards from each instance of a European number + // until the first strong type (R, L, AL, or sor) is found. If an + // AL is found, change the type of the European number to Arabic + // number. + // W3. Change all ALs to R. + for (var i$2 = 0, cur = outerType; i$2 < len; ++i$2) { + var type$1 = types[i$2]; + if (type$1 == "1" && cur == "r") { types[i$2] = "n"; } + else if (isStrong.test(type$1)) { cur = type$1; if (type$1 == "r") { types[i$2] = "R"; } } + } + + // W4. A single European separator between two European numbers + // changes to a European number. A single common separator between + // two numbers of the same type changes to that type. + for (var i$3 = 1, prev$1 = types[0]; i$3 < len - 1; ++i$3) { + var type$2 = types[i$3]; + if (type$2 == "+" && prev$1 == "1" && types[i$3+1] == "1") { types[i$3] = "1"; } + else if (type$2 == "," && prev$1 == types[i$3+1] && + (prev$1 == "1" || prev$1 == "n")) { types[i$3] = prev$1; } + prev$1 = type$2; + } + + // W5. A sequence of European terminators adjacent to European + // numbers changes to all European numbers. + // W6. Otherwise, separators and terminators change to Other + // Neutral. + for (var i$4 = 0; i$4 < len; ++i$4) { + var type$3 = types[i$4]; + if (type$3 == ",") { types[i$4] = "N"; } + else if (type$3 == "%") { + var end = (void 0); + for (end = i$4 + 1; end < len && types[end] == "%"; ++end) {} + var replace = (i$4 && types[i$4-1] == "!") || (end < len && types[end] == "1") ? "1" : "N"; + for (var j = i$4; j < end; ++j) { types[j] = replace; } + i$4 = end - 1; + } + } + + // W7. Search backwards from each instance of a European number + // until the first strong type (R, L, or sor) is found. If an L is + // found, then change the type of the European number to L. + for (var i$5 = 0, cur$1 = outerType; i$5 < len; ++i$5) { + var type$4 = types[i$5]; + if (cur$1 == "L" && type$4 == "1") { types[i$5] = "L"; } + else if (isStrong.test(type$4)) { cur$1 = type$4; } + } + + // N1. A sequence of neutrals takes the direction of the + // surrounding strong text if the text on both sides has the same + // direction. European and Arabic numbers act as if they were R in + // terms of their influence on neutrals. Start-of-level-run (sor) + // and end-of-level-run (eor) are used at level run boundaries. + // N2. Any remaining neutrals take the embedding direction. + for (var i$6 = 0; i$6 < len; ++i$6) { + if (isNeutral.test(types[i$6])) { + var end$1 = (void 0); + for (end$1 = i$6 + 1; end$1 < len && isNeutral.test(types[end$1]); ++end$1) {} + var before = (i$6 ? types[i$6-1] : outerType) == "L"; + var after = (end$1 < len ? types[end$1] : outerType) == "L"; + var replace$1 = before == after ? (before ? "L" : "R") : outerType; + for (var j$1 = i$6; j$1 < end$1; ++j$1) { types[j$1] = replace$1; } + i$6 = end$1 - 1; + } + } + + // Here we depart from the documented algorithm, in order to avoid + // building up an actual levels array. Since there are only three + // levels (0, 1, 2) in an implementation that doesn't take + // explicit embedding into account, we can build up the order on + // the fly, without following the level-based algorithm. + var order = [], m; + for (var i$7 = 0; i$7 < len;) { + if (countsAsLeft.test(types[i$7])) { + var start = i$7; + for (++i$7; i$7 < len && countsAsLeft.test(types[i$7]); ++i$7) {} + order.push(new BidiSpan(0, start, i$7)); + } else { + var pos = i$7, at = order.length, isRTL = direction == "rtl" ? 1 : 0; + for (++i$7; i$7 < len && types[i$7] != "L"; ++i$7) {} + for (var j$2 = pos; j$2 < i$7;) { + if (countsAsNum.test(types[j$2])) { + if (pos < j$2) { order.splice(at, 0, new BidiSpan(1, pos, j$2)); at += isRTL; } + var nstart = j$2; + for (++j$2; j$2 < i$7 && countsAsNum.test(types[j$2]); ++j$2) {} + order.splice(at, 0, new BidiSpan(2, nstart, j$2)); + at += isRTL; + pos = j$2; + } else { ++j$2; } + } + if (pos < i$7) { order.splice(at, 0, new BidiSpan(1, pos, i$7)); } + } + } + if (direction == "ltr") { + if (order[0].level == 1 && (m = str.match(/^\s+/))) { + order[0].from = m[0].length; + order.unshift(new BidiSpan(0, 0, m[0].length)); + } + if (lst(order).level == 1 && (m = str.match(/\s+$/))) { + lst(order).to -= m[0].length; + order.push(new BidiSpan(0, len - m[0].length, len)); + } + } + + return direction == "rtl" ? order.reverse() : order + } + })(); + + // Get the bidi ordering for the given line (and cache it). Returns + // false for lines that are fully left-to-right, and an array of + // BidiSpan objects otherwise. + function getOrder(line, direction) { + var order = line.order; + if (order == null) { order = line.order = bidiOrdering(line.text, direction); } + return order + } + + // EVENT HANDLING + + // Lightweight event framework. on/off also work on DOM nodes, + // registering native DOM handlers. + + var noHandlers = []; + + var on = function(emitter, type, f) { + if (emitter.addEventListener) { + emitter.addEventListener(type, f, false); + } else if (emitter.attachEvent) { + emitter.attachEvent("on" + type, f); + } else { + var map = emitter._handlers || (emitter._handlers = {}); + map[type] = (map[type] || noHandlers).concat(f); + } + }; + + function getHandlers(emitter, type) { + return emitter._handlers && emitter._handlers[type] || noHandlers + } + + function off(emitter, type, f) { + if (emitter.removeEventListener) { + emitter.removeEventListener(type, f, false); + } else if (emitter.detachEvent) { + emitter.detachEvent("on" + type, f); + } else { + var map = emitter._handlers, arr = map && map[type]; + if (arr) { + var index = indexOf(arr, f); + if (index > -1) + { map[type] = arr.slice(0, index).concat(arr.slice(index + 1)); } + } + } + } + + function signal(emitter, type /*, values...*/) { + var handlers = getHandlers(emitter, type); + if (!handlers.length) { return } + var args = Array.prototype.slice.call(arguments, 2); + for (var i = 0; i < handlers.length; ++i) { handlers[i].apply(null, args); } + } + + // The DOM events that CodeMirror handles can be overridden by + // registering a (non-DOM) handler on the editor for the event name, + // and preventDefault-ing the event in that handler. + function signalDOMEvent(cm, e, override) { + if (typeof e == "string") + { e = {type: e, preventDefault: function() { this.defaultPrevented = true; }}; } + signal(cm, override || e.type, cm, e); + return e_defaultPrevented(e) || e.codemirrorIgnore + } + + function signalCursorActivity(cm) { + var arr = cm._handlers && cm._handlers.cursorActivity; + if (!arr) { return } + var set = cm.curOp.cursorActivityHandlers || (cm.curOp.cursorActivityHandlers = []); + for (var i = 0; i < arr.length; ++i) { if (indexOf(set, arr[i]) == -1) + { set.push(arr[i]); } } + } + + function hasHandler(emitter, type) { + return getHandlers(emitter, type).length > 0 + } + + // Add on and off methods to a constructor's prototype, to make + // registering events on such objects more convenient. + function eventMixin(ctor) { + ctor.prototype.on = function(type, f) {on(this, type, f);}; + ctor.prototype.off = function(type, f) {off(this, type, f);}; + } + + // Due to the fact that we still support jurassic IE versions, some + // compatibility wrappers are needed. + + function e_preventDefault(e) { + if (e.preventDefault) { e.preventDefault(); } + else { e.returnValue = false; } + } + function e_stopPropagation(e) { + if (e.stopPropagation) { e.stopPropagation(); } + else { e.cancelBubble = true; } + } + function e_defaultPrevented(e) { + return e.defaultPrevented != null ? e.defaultPrevented : e.returnValue == false + } + function e_stop(e) {e_preventDefault(e); e_stopPropagation(e);} + + function e_target(e) {return e.target || e.srcElement} + function e_button(e) { + var b = e.which; + if (b == null) { + if (e.button & 1) { b = 1; } + else if (e.button & 2) { b = 3; } + else if (e.button & 4) { b = 2; } + } + if (mac && e.ctrlKey && b == 1) { b = 3; } + return b + } + + // Detect drag-and-drop + var dragAndDrop = function() { + // There is *some* kind of drag-and-drop support in IE6-8, but I + // couldn't get it to work yet. + if (ie && ie_version < 9) { return false } + var div = elt('div'); + return "draggable" in div || "dragDrop" in div + }(); + + var zwspSupported; + function zeroWidthElement(measure) { + if (zwspSupported == null) { + var test = elt("span", "\u200b"); + removeChildrenAndAdd(measure, elt("span", [test, document.createTextNode("x")])); + if (measure.firstChild.offsetHeight != 0) + { zwspSupported = test.offsetWidth <= 1 && test.offsetHeight > 2 && !(ie && ie_version < 8); } + } + var node = zwspSupported ? elt("span", "\u200b") : + elt("span", "\u00a0", null, "display: inline-block; width: 1px; margin-right: -1px"); + node.setAttribute("cm-text", ""); + return node + } + + // Feature-detect IE's crummy client rect reporting for bidi text + var badBidiRects; + function hasBadBidiRects(measure) { + if (badBidiRects != null) { return badBidiRects } + var txt = removeChildrenAndAdd(measure, document.createTextNode("A\u062eA")); + var r0 = range(txt, 0, 1).getBoundingClientRect(); + var r1 = range(txt, 1, 2).getBoundingClientRect(); + removeChildren(measure); + if (!r0 || r0.left == r0.right) { return false } // Safari returns null in some cases (#2780) + return badBidiRects = (r1.right - r0.right < 3) + } + + // See if "".split is the broken IE version, if so, provide an + // alternative way to split lines. + var splitLinesAuto = "\n\nb".split(/\n/).length != 3 ? function (string) { + var pos = 0, result = [], l = string.length; + while (pos <= l) { + var nl = string.indexOf("\n", pos); + if (nl == -1) { nl = string.length; } + var line = string.slice(pos, string.charAt(nl - 1) == "\r" ? nl - 1 : nl); + var rt = line.indexOf("\r"); + if (rt != -1) { + result.push(line.slice(0, rt)); + pos += rt + 1; + } else { + result.push(line); + pos = nl + 1; + } + } + return result + } : function (string) { return string.split(/\r\n?|\n/); }; + + var hasSelection = window.getSelection ? function (te) { + try { return te.selectionStart != te.selectionEnd } + catch(e) { return false } + } : function (te) { + var range; + try {range = te.ownerDocument.selection.createRange();} + catch(e) {} + if (!range || range.parentElement() != te) { return false } + return range.compareEndPoints("StartToEnd", range) != 0 + }; + + var hasCopyEvent = (function () { + var e = elt("div"); + if ("oncopy" in e) { return true } + e.setAttribute("oncopy", "return;"); + return typeof e.oncopy == "function" + })(); + + var badZoomedRects = null; + function hasBadZoomedRects(measure) { + if (badZoomedRects != null) { return badZoomedRects } + var node = removeChildrenAndAdd(measure, elt("span", "x")); + var normal = node.getBoundingClientRect(); + var fromRange = range(node, 0, 1).getBoundingClientRect(); + return badZoomedRects = Math.abs(normal.left - fromRange.left) > 1 + } + + // Known modes, by name and by MIME + var modes = {}, mimeModes = {}; + + // Extra arguments are stored as the mode's dependencies, which is + // used by (legacy) mechanisms like loadmode.js to automatically + // load a mode. (Preferred mechanism is the require/define calls.) + function defineMode(name, mode) { + if (arguments.length > 2) + { mode.dependencies = Array.prototype.slice.call(arguments, 2); } + modes[name] = mode; + } + + function defineMIME(mime, spec) { + mimeModes[mime] = spec; + } + + // Given a MIME type, a {name, ...options} config object, or a name + // string, return a mode config object. + function resolveMode(spec) { + if (typeof spec == "string" && mimeModes.hasOwnProperty(spec)) { + spec = mimeModes[spec]; + } else if (spec && typeof spec.name == "string" && mimeModes.hasOwnProperty(spec.name)) { + var found = mimeModes[spec.name]; + if (typeof found == "string") { found = {name: found}; } + spec = createObj(found, spec); + spec.name = found.name; + } else if (typeof spec == "string" && /^[\w\-]+\/[\w\-]+\+xml$/.test(spec)) { + return resolveMode("application/xml") + } else if (typeof spec == "string" && /^[\w\-]+\/[\w\-]+\+json$/.test(spec)) { + return resolveMode("application/json") + } + if (typeof spec == "string") { return {name: spec} } + else { return spec || {name: "null"} } + } + + // Given a mode spec (anything that resolveMode accepts), find and + // initialize an actual mode object. + function getMode(options, spec) { + spec = resolveMode(spec); + var mfactory = modes[spec.name]; + if (!mfactory) { return getMode(options, "text/plain") } + var modeObj = mfactory(options, spec); + if (modeExtensions.hasOwnProperty(spec.name)) { + var exts = modeExtensions[spec.name]; + for (var prop in exts) { + if (!exts.hasOwnProperty(prop)) { continue } + if (modeObj.hasOwnProperty(prop)) { modeObj["_" + prop] = modeObj[prop]; } + modeObj[prop] = exts[prop]; + } + } + modeObj.name = spec.name; + if (spec.helperType) { modeObj.helperType = spec.helperType; } + if (spec.modeProps) { for (var prop$1 in spec.modeProps) + { modeObj[prop$1] = spec.modeProps[prop$1]; } } + + return modeObj + } + + // This can be used to attach properties to mode objects from + // outside the actual mode definition. + var modeExtensions = {}; + function extendMode(mode, properties) { + var exts = modeExtensions.hasOwnProperty(mode) ? modeExtensions[mode] : (modeExtensions[mode] = {}); + copyObj(properties, exts); + } + + function copyState(mode, state) { + if (state === true) { return state } + if (mode.copyState) { return mode.copyState(state) } + var nstate = {}; + for (var n in state) { + var val = state[n]; + if (val instanceof Array) { val = val.concat([]); } + nstate[n] = val; + } + return nstate + } + + // Given a mode and a state (for that mode), find the inner mode and + // state at the position that the state refers to. + function innerMode(mode, state) { + var info; + while (mode.innerMode) { + info = mode.innerMode(state); + if (!info || info.mode == mode) { break } + state = info.state; + mode = info.mode; + } + return info || {mode: mode, state: state} + } + + function startState(mode, a1, a2) { + return mode.startState ? mode.startState(a1, a2) : true + } + + // STRING STREAM + + // Fed to the mode parsers, provides helper functions to make + // parsers more succinct. + + var StringStream = function(string, tabSize, lineOracle) { + this.pos = this.start = 0; + this.string = string; + this.tabSize = tabSize || 8; + this.lastColumnPos = this.lastColumnValue = 0; + this.lineStart = 0; + this.lineOracle = lineOracle; + }; + + StringStream.prototype.eol = function () {return this.pos >= this.string.length}; + StringStream.prototype.sol = function () {return this.pos == this.lineStart}; + StringStream.prototype.peek = function () {return this.string.charAt(this.pos) || undefined}; + StringStream.prototype.next = function () { + if (this.pos < this.string.length) + { return this.string.charAt(this.pos++) } + }; + StringStream.prototype.eat = function (match) { + var ch = this.string.charAt(this.pos); + var ok; + if (typeof match == "string") { ok = ch == match; } + else { ok = ch && (match.test ? match.test(ch) : match(ch)); } + if (ok) {++this.pos; return ch} + }; + StringStream.prototype.eatWhile = function (match) { + var start = this.pos; + while (this.eat(match)){} + return this.pos > start + }; + StringStream.prototype.eatSpace = function () { + var start = this.pos; + while (/[\s\u00a0]/.test(this.string.charAt(this.pos))) { ++this.pos; } + return this.pos > start + }; + StringStream.prototype.skipToEnd = function () {this.pos = this.string.length;}; + StringStream.prototype.skipTo = function (ch) { + var found = this.string.indexOf(ch, this.pos); + if (found > -1) {this.pos = found; return true} + }; + StringStream.prototype.backUp = function (n) {this.pos -= n;}; + StringStream.prototype.column = function () { + if (this.lastColumnPos < this.start) { + this.lastColumnValue = countColumn(this.string, this.start, this.tabSize, this.lastColumnPos, this.lastColumnValue); + this.lastColumnPos = this.start; + } + return this.lastColumnValue - (this.lineStart ? countColumn(this.string, this.lineStart, this.tabSize) : 0) + }; + StringStream.prototype.indentation = function () { + return countColumn(this.string, null, this.tabSize) - + (this.lineStart ? countColumn(this.string, this.lineStart, this.tabSize) : 0) + }; + StringStream.prototype.match = function (pattern, consume, caseInsensitive) { + if (typeof pattern == "string") { + var cased = function (str) { return caseInsensitive ? str.toLowerCase() : str; }; + var substr = this.string.substr(this.pos, pattern.length); + if (cased(substr) == cased(pattern)) { + if (consume !== false) { this.pos += pattern.length; } + return true + } + } else { + var match = this.string.slice(this.pos).match(pattern); + if (match && match.index > 0) { return null } + if (match && consume !== false) { this.pos += match[0].length; } + return match + } + }; + StringStream.prototype.current = function (){return this.string.slice(this.start, this.pos)}; + StringStream.prototype.hideFirstChars = function (n, inner) { + this.lineStart += n; + try { return inner() } + finally { this.lineStart -= n; } + }; + StringStream.prototype.lookAhead = function (n) { + var oracle = this.lineOracle; + return oracle && oracle.lookAhead(n) + }; + StringStream.prototype.baseToken = function () { + var oracle = this.lineOracle; + return oracle && oracle.baseToken(this.pos) + }; + + // Find the line object corresponding to the given line number. + function getLine(doc, n) { + n -= doc.first; + if (n < 0 || n >= doc.size) { throw new Error("There is no line " + (n + doc.first) + " in the document.") } + var chunk = doc; + while (!chunk.lines) { + for (var i = 0;; ++i) { + var child = chunk.children[i], sz = child.chunkSize(); + if (n < sz) { chunk = child; break } + n -= sz; + } + } + return chunk.lines[n] + } + + // Get the part of a document between two positions, as an array of + // strings. + function getBetween(doc, start, end) { + var out = [], n = start.line; + doc.iter(start.line, end.line + 1, function (line) { + var text = line.text; + if (n == end.line) { text = text.slice(0, end.ch); } + if (n == start.line) { text = text.slice(start.ch); } + out.push(text); + ++n; + }); + return out + } + // Get the lines between from and to, as array of strings. + function getLines(doc, from, to) { + var out = []; + doc.iter(from, to, function (line) { out.push(line.text); }); // iter aborts when callback returns truthy value + return out + } + + // Update the height of a line, propagating the height change + // upwards to parent nodes. + function updateLineHeight(line, height) { + var diff = height - line.height; + if (diff) { for (var n = line; n; n = n.parent) { n.height += diff; } } + } + + // Given a line object, find its line number by walking up through + // its parent links. + function lineNo(line) { + if (line.parent == null) { return null } + var cur = line.parent, no = indexOf(cur.lines, line); + for (var chunk = cur.parent; chunk; cur = chunk, chunk = chunk.parent) { + for (var i = 0;; ++i) { + if (chunk.children[i] == cur) { break } + no += chunk.children[i].chunkSize(); + } + } + return no + cur.first + } + + // Find the line at the given vertical position, using the height + // information in the document tree. + function lineAtHeight(chunk, h) { + var n = chunk.first; + outer: do { + for (var i$1 = 0; i$1 < chunk.children.length; ++i$1) { + var child = chunk.children[i$1], ch = child.height; + if (h < ch) { chunk = child; continue outer } + h -= ch; + n += child.chunkSize(); + } + return n + } while (!chunk.lines) + var i = 0; + for (; i < chunk.lines.length; ++i) { + var line = chunk.lines[i], lh = line.height; + if (h < lh) { break } + h -= lh; + } + return n + i + } + + function isLine(doc, l) {return l >= doc.first && l < doc.first + doc.size} + + function lineNumberFor(options, i) { + return String(options.lineNumberFormatter(i + options.firstLineNumber)) + } + + // A Pos instance represents a position within the text. + function Pos(line, ch, sticky) { + if ( sticky === void 0 ) sticky = null; + + if (!(this instanceof Pos)) { return new Pos(line, ch, sticky) } + this.line = line; + this.ch = ch; + this.sticky = sticky; + } + + // Compare two positions, return 0 if they are the same, a negative + // number when a is less, and a positive number otherwise. + function cmp(a, b) { return a.line - b.line || a.ch - b.ch } + + function equalCursorPos(a, b) { return a.sticky == b.sticky && cmp(a, b) == 0 } + + function copyPos(x) {return Pos(x.line, x.ch)} + function maxPos(a, b) { return cmp(a, b) < 0 ? b : a } + function minPos(a, b) { return cmp(a, b) < 0 ? a : b } + + // Most of the external API clips given positions to make sure they + // actually exist within the document. + function clipLine(doc, n) {return Math.max(doc.first, Math.min(n, doc.first + doc.size - 1))} + function clipPos(doc, pos) { + if (pos.line < doc.first) { return Pos(doc.first, 0) } + var last = doc.first + doc.size - 1; + if (pos.line > last) { return Pos(last, getLine(doc, last).text.length) } + return clipToLen(pos, getLine(doc, pos.line).text.length) + } + function clipToLen(pos, linelen) { + var ch = pos.ch; + if (ch == null || ch > linelen) { return Pos(pos.line, linelen) } + else if (ch < 0) { return Pos(pos.line, 0) } + else { return pos } + } + function clipPosArray(doc, array) { + var out = []; + for (var i = 0; i < array.length; i++) { out[i] = clipPos(doc, array[i]); } + return out + } + + var SavedContext = function(state, lookAhead) { + this.state = state; + this.lookAhead = lookAhead; + }; + + var Context = function(doc, state, line, lookAhead) { + this.state = state; + this.doc = doc; + this.line = line; + this.maxLookAhead = lookAhead || 0; + this.baseTokens = null; + this.baseTokenPos = 1; + }; + + Context.prototype.lookAhead = function (n) { + var line = this.doc.getLine(this.line + n); + if (line != null && n > this.maxLookAhead) { this.maxLookAhead = n; } + return line + }; + + Context.prototype.baseToken = function (n) { + if (!this.baseTokens) { return null } + while (this.baseTokens[this.baseTokenPos] <= n) + { this.baseTokenPos += 2; } + var type = this.baseTokens[this.baseTokenPos + 1]; + return {type: type && type.replace(/( |^)overlay .*/, ""), + size: this.baseTokens[this.baseTokenPos] - n} + }; + + Context.prototype.nextLine = function () { + this.line++; + if (this.maxLookAhead > 0) { this.maxLookAhead--; } + }; + + Context.fromSaved = function (doc, saved, line) { + if (saved instanceof SavedContext) + { return new Context(doc, copyState(doc.mode, saved.state), line, saved.lookAhead) } + else + { return new Context(doc, copyState(doc.mode, saved), line) } + }; + + Context.prototype.save = function (copy) { + var state = copy !== false ? copyState(this.doc.mode, this.state) : this.state; + return this.maxLookAhead > 0 ? new SavedContext(state, this.maxLookAhead) : state + }; + + + // Compute a style array (an array starting with a mode generation + // -- for invalidation -- followed by pairs of end positions and + // style strings), which is used to highlight the tokens on the + // line. + function highlightLine(cm, line, context, forceToEnd) { + // A styles array always starts with a number identifying the + // mode/overlays that it is based on (for easy invalidation). + var st = [cm.state.modeGen], lineClasses = {}; + // Compute the base array of styles + runMode(cm, line.text, cm.doc.mode, context, function (end, style) { return st.push(end, style); }, + lineClasses, forceToEnd); + var state = context.state; + + // Run overlays, adjust style array. + var loop = function ( o ) { + context.baseTokens = st; + var overlay = cm.state.overlays[o], i = 1, at = 0; + context.state = true; + runMode(cm, line.text, overlay.mode, context, function (end, style) { + var start = i; + // Ensure there's a token end at the current position, and that i points at it + while (at < end) { + var i_end = st[i]; + if (i_end > end) + { st.splice(i, 1, end, st[i+1], i_end); } + i += 2; + at = Math.min(end, i_end); + } + if (!style) { return } + if (overlay.opaque) { + st.splice(start, i - start, end, "overlay " + style); + i = start + 2; + } else { + for (; start < i; start += 2) { + var cur = st[start+1]; + st[start+1] = (cur ? cur + " " : "") + "overlay " + style; + } + } + }, lineClasses); + context.state = state; + context.baseTokens = null; + context.baseTokenPos = 1; + }; + + for (var o = 0; o < cm.state.overlays.length; ++o) loop( o ); + + return {styles: st, classes: lineClasses.bgClass || lineClasses.textClass ? lineClasses : null} + } + + function getLineStyles(cm, line, updateFrontier) { + if (!line.styles || line.styles[0] != cm.state.modeGen) { + var context = getContextBefore(cm, lineNo(line)); + var resetState = line.text.length > cm.options.maxHighlightLength && copyState(cm.doc.mode, context.state); + var result = highlightLine(cm, line, context); + if (resetState) { context.state = resetState; } + line.stateAfter = context.save(!resetState); + line.styles = result.styles; + if (result.classes) { line.styleClasses = result.classes; } + else if (line.styleClasses) { line.styleClasses = null; } + if (updateFrontier === cm.doc.highlightFrontier) + { cm.doc.modeFrontier = Math.max(cm.doc.modeFrontier, ++cm.doc.highlightFrontier); } + } + return line.styles + } + + function getContextBefore(cm, n, precise) { + var doc = cm.doc, display = cm.display; + if (!doc.mode.startState) { return new Context(doc, true, n) } + var start = findStartLine(cm, n, precise); + var saved = start > doc.first && getLine(doc, start - 1).stateAfter; + var context = saved ? Context.fromSaved(doc, saved, start) : new Context(doc, startState(doc.mode), start); + + doc.iter(start, n, function (line) { + processLine(cm, line.text, context); + var pos = context.line; + line.stateAfter = pos == n - 1 || pos % 5 == 0 || pos >= display.viewFrom && pos < display.viewTo ? context.save() : null; + context.nextLine(); + }); + if (precise) { doc.modeFrontier = context.line; } + return context + } + + // Lightweight form of highlight -- proceed over this line and + // update state, but don't save a style array. Used for lines that + // aren't currently visible. + function processLine(cm, text, context, startAt) { + var mode = cm.doc.mode; + var stream = new StringStream(text, cm.options.tabSize, context); + stream.start = stream.pos = startAt || 0; + if (text == "") { callBlankLine(mode, context.state); } + while (!stream.eol()) { + readToken(mode, stream, context.state); + stream.start = stream.pos; + } + } + + function callBlankLine(mode, state) { + if (mode.blankLine) { return mode.blankLine(state) } + if (!mode.innerMode) { return } + var inner = innerMode(mode, state); + if (inner.mode.blankLine) { return inner.mode.blankLine(inner.state) } + } + + function readToken(mode, stream, state, inner) { + for (var i = 0; i < 10; i++) { + if (inner) { inner[0] = innerMode(mode, state).mode; } + var style = mode.token(stream, state); + if (stream.pos > stream.start) { return style } + } + throw new Error("Mode " + mode.name + " failed to advance stream.") + } + + var Token = function(stream, type, state) { + this.start = stream.start; this.end = stream.pos; + this.string = stream.current(); + this.type = type || null; + this.state = state; + }; + + // Utility for getTokenAt and getLineTokens + function takeToken(cm, pos, precise, asArray) { + var doc = cm.doc, mode = doc.mode, style; + pos = clipPos(doc, pos); + var line = getLine(doc, pos.line), context = getContextBefore(cm, pos.line, precise); + var stream = new StringStream(line.text, cm.options.tabSize, context), tokens; + if (asArray) { tokens = []; } + while ((asArray || stream.pos < pos.ch) && !stream.eol()) { + stream.start = stream.pos; + style = readToken(mode, stream, context.state); + if (asArray) { tokens.push(new Token(stream, style, copyState(doc.mode, context.state))); } + } + return asArray ? tokens : new Token(stream, style, context.state) + } + + function extractLineClasses(type, output) { + if (type) { for (;;) { + var lineClass = type.match(/(?:^|\s+)line-(background-)?(\S+)/); + if (!lineClass) { break } + type = type.slice(0, lineClass.index) + type.slice(lineClass.index + lineClass[0].length); + var prop = lineClass[1] ? "bgClass" : "textClass"; + if (output[prop] == null) + { output[prop] = lineClass[2]; } + else if (!(new RegExp("(?:^|\\s)" + lineClass[2] + "(?:$|\\s)")).test(output[prop])) + { output[prop] += " " + lineClass[2]; } + } } + return type + } + + // Run the given mode's parser over a line, calling f for each token. + function runMode(cm, text, mode, context, f, lineClasses, forceToEnd) { + var flattenSpans = mode.flattenSpans; + if (flattenSpans == null) { flattenSpans = cm.options.flattenSpans; } + var curStart = 0, curStyle = null; + var stream = new StringStream(text, cm.options.tabSize, context), style; + var inner = cm.options.addModeClass && [null]; + if (text == "") { extractLineClasses(callBlankLine(mode, context.state), lineClasses); } + while (!stream.eol()) { + if (stream.pos > cm.options.maxHighlightLength) { + flattenSpans = false; + if (forceToEnd) { processLine(cm, text, context, stream.pos); } + stream.pos = text.length; + style = null; + } else { + style = extractLineClasses(readToken(mode, stream, context.state, inner), lineClasses); + } + if (inner) { + var mName = inner[0].name; + if (mName) { style = "m-" + (style ? mName + " " + style : mName); } + } + if (!flattenSpans || curStyle != style) { + while (curStart < stream.start) { + curStart = Math.min(stream.start, curStart + 5000); + f(curStart, curStyle); + } + curStyle = style; + } + stream.start = stream.pos; + } + while (curStart < stream.pos) { + // Webkit seems to refuse to render text nodes longer than 57444 + // characters, and returns inaccurate measurements in nodes + // starting around 5000 chars. + var pos = Math.min(stream.pos, curStart + 5000); + f(pos, curStyle); + curStart = pos; + } + } + + // Finds the line to start with when starting a parse. Tries to + // find a line with a stateAfter, so that it can start with a + // valid state. If that fails, it returns the line with the + // smallest indentation, which tends to need the least context to + // parse correctly. + function findStartLine(cm, n, precise) { + var minindent, minline, doc = cm.doc; + var lim = precise ? -1 : n - (cm.doc.mode.innerMode ? 1000 : 100); + for (var search = n; search > lim; --search) { + if (search <= doc.first) { return doc.first } + var line = getLine(doc, search - 1), after = line.stateAfter; + if (after && (!precise || search + (after instanceof SavedContext ? after.lookAhead : 0) <= doc.modeFrontier)) + { return search } + var indented = countColumn(line.text, null, cm.options.tabSize); + if (minline == null || minindent > indented) { + minline = search - 1; + minindent = indented; + } + } + return minline + } + + function retreatFrontier(doc, n) { + doc.modeFrontier = Math.min(doc.modeFrontier, n); + if (doc.highlightFrontier < n - 10) { return } + var start = doc.first; + for (var line = n - 1; line > start; line--) { + var saved = getLine(doc, line).stateAfter; + // change is on 3 + // state on line 1 looked ahead 2 -- so saw 3 + // test 1 + 2 < 3 should cover this + if (saved && (!(saved instanceof SavedContext) || line + saved.lookAhead < n)) { + start = line + 1; + break + } + } + doc.highlightFrontier = Math.min(doc.highlightFrontier, start); + } + + // Optimize some code when these features are not used. + var sawReadOnlySpans = false, sawCollapsedSpans = false; + + function seeReadOnlySpans() { + sawReadOnlySpans = true; + } + + function seeCollapsedSpans() { + sawCollapsedSpans = true; + } + + // TEXTMARKER SPANS + + function MarkedSpan(marker, from, to) { + this.marker = marker; + this.from = from; this.to = to; + } + + // Search an array of spans for a span matching the given marker. + function getMarkedSpanFor(spans, marker) { + if (spans) { for (var i = 0; i < spans.length; ++i) { + var span = spans[i]; + if (span.marker == marker) { return span } + } } + } + + // Remove a span from an array, returning undefined if no spans are + // left (we don't store arrays for lines without spans). + function removeMarkedSpan(spans, span) { + var r; + for (var i = 0; i < spans.length; ++i) + { if (spans[i] != span) { (r || (r = [])).push(spans[i]); } } + return r + } + + // Add a span to a line. + function addMarkedSpan(line, span, op) { + var inThisOp = op && window.WeakSet && (op.markedSpans || (op.markedSpans = new WeakSet)); + if (inThisOp && line.markedSpans && inThisOp.has(line.markedSpans)) { + line.markedSpans.push(span); + } else { + line.markedSpans = line.markedSpans ? line.markedSpans.concat([span]) : [span]; + if (inThisOp) { inThisOp.add(line.markedSpans); } + } + span.marker.attachLine(line); + } + + // Used for the algorithm that adjusts markers for a change in the + // document. These functions cut an array of spans at a given + // character position, returning an array of remaining chunks (or + // undefined if nothing remains). + function markedSpansBefore(old, startCh, isInsert) { + var nw; + if (old) { for (var i = 0; i < old.length; ++i) { + var span = old[i], marker = span.marker; + var startsBefore = span.from == null || (marker.inclusiveLeft ? span.from <= startCh : span.from < startCh); + if (startsBefore || span.from == startCh && marker.type == "bookmark" && (!isInsert || !span.marker.insertLeft)) { + var endsAfter = span.to == null || (marker.inclusiveRight ? span.to >= startCh : span.to > startCh) + ;(nw || (nw = [])).push(new MarkedSpan(marker, span.from, endsAfter ? null : span.to)); + } + } } + return nw + } + function markedSpansAfter(old, endCh, isInsert) { + var nw; + if (old) { for (var i = 0; i < old.length; ++i) { + var span = old[i], marker = span.marker; + var endsAfter = span.to == null || (marker.inclusiveRight ? span.to >= endCh : span.to > endCh); + if (endsAfter || span.from == endCh && marker.type == "bookmark" && (!isInsert || span.marker.insertLeft)) { + var startsBefore = span.from == null || (marker.inclusiveLeft ? span.from <= endCh : span.from < endCh) + ;(nw || (nw = [])).push(new MarkedSpan(marker, startsBefore ? null : span.from - endCh, + span.to == null ? null : span.to - endCh)); + } + } } + return nw + } + + // Given a change object, compute the new set of marker spans that + // cover the line in which the change took place. Removes spans + // entirely within the change, reconnects spans belonging to the + // same marker that appear on both sides of the change, and cuts off + // spans partially within the change. Returns an array of span + // arrays with one element for each line in (after) the change. + function stretchSpansOverChange(doc, change) { + if (change.full) { return null } + var oldFirst = isLine(doc, change.from.line) && getLine(doc, change.from.line).markedSpans; + var oldLast = isLine(doc, change.to.line) && getLine(doc, change.to.line).markedSpans; + if (!oldFirst && !oldLast) { return null } + + var startCh = change.from.ch, endCh = change.to.ch, isInsert = cmp(change.from, change.to) == 0; + // Get the spans that 'stick out' on both sides + var first = markedSpansBefore(oldFirst, startCh, isInsert); + var last = markedSpansAfter(oldLast, endCh, isInsert); + + // Next, merge those two ends + var sameLine = change.text.length == 1, offset = lst(change.text).length + (sameLine ? startCh : 0); + if (first) { + // Fix up .to properties of first + for (var i = 0; i < first.length; ++i) { + var span = first[i]; + if (span.to == null) { + var found = getMarkedSpanFor(last, span.marker); + if (!found) { span.to = startCh; } + else if (sameLine) { span.to = found.to == null ? null : found.to + offset; } + } + } + } + if (last) { + // Fix up .from in last (or move them into first in case of sameLine) + for (var i$1 = 0; i$1 < last.length; ++i$1) { + var span$1 = last[i$1]; + if (span$1.to != null) { span$1.to += offset; } + if (span$1.from == null) { + var found$1 = getMarkedSpanFor(first, span$1.marker); + if (!found$1) { + span$1.from = offset; + if (sameLine) { (first || (first = [])).push(span$1); } + } + } else { + span$1.from += offset; + if (sameLine) { (first || (first = [])).push(span$1); } + } + } + } + // Make sure we didn't create any zero-length spans + if (first) { first = clearEmptySpans(first); } + if (last && last != first) { last = clearEmptySpans(last); } + + var newMarkers = [first]; + if (!sameLine) { + // Fill gap with whole-line-spans + var gap = change.text.length - 2, gapMarkers; + if (gap > 0 && first) + { for (var i$2 = 0; i$2 < first.length; ++i$2) + { if (first[i$2].to == null) + { (gapMarkers || (gapMarkers = [])).push(new MarkedSpan(first[i$2].marker, null, null)); } } } + for (var i$3 = 0; i$3 < gap; ++i$3) + { newMarkers.push(gapMarkers); } + newMarkers.push(last); + } + return newMarkers + } + + // Remove spans that are empty and don't have a clearWhenEmpty + // option of false. + function clearEmptySpans(spans) { + for (var i = 0; i < spans.length; ++i) { + var span = spans[i]; + if (span.from != null && span.from == span.to && span.marker.clearWhenEmpty !== false) + { spans.splice(i--, 1); } + } + if (!spans.length) { return null } + return spans + } + + // Used to 'clip' out readOnly ranges when making a change. + function removeReadOnlyRanges(doc, from, to) { + var markers = null; + doc.iter(from.line, to.line + 1, function (line) { + if (line.markedSpans) { for (var i = 0; i < line.markedSpans.length; ++i) { + var mark = line.markedSpans[i].marker; + if (mark.readOnly && (!markers || indexOf(markers, mark) == -1)) + { (markers || (markers = [])).push(mark); } + } } + }); + if (!markers) { return null } + var parts = [{from: from, to: to}]; + for (var i = 0; i < markers.length; ++i) { + var mk = markers[i], m = mk.find(0); + for (var j = 0; j < parts.length; ++j) { + var p = parts[j]; + if (cmp(p.to, m.from) < 0 || cmp(p.from, m.to) > 0) { continue } + var newParts = [j, 1], dfrom = cmp(p.from, m.from), dto = cmp(p.to, m.to); + if (dfrom < 0 || !mk.inclusiveLeft && !dfrom) + { newParts.push({from: p.from, to: m.from}); } + if (dto > 0 || !mk.inclusiveRight && !dto) + { newParts.push({from: m.to, to: p.to}); } + parts.splice.apply(parts, newParts); + j += newParts.length - 3; + } + } + return parts + } + + // Connect or disconnect spans from a line. + function detachMarkedSpans(line) { + var spans = line.markedSpans; + if (!spans) { return } + for (var i = 0; i < spans.length; ++i) + { spans[i].marker.detachLine(line); } + line.markedSpans = null; + } + function attachMarkedSpans(line, spans) { + if (!spans) { return } + for (var i = 0; i < spans.length; ++i) + { spans[i].marker.attachLine(line); } + line.markedSpans = spans; + } + + // Helpers used when computing which overlapping collapsed span + // counts as the larger one. + function extraLeft(marker) { return marker.inclusiveLeft ? -1 : 0 } + function extraRight(marker) { return marker.inclusiveRight ? 1 : 0 } + + // Returns a number indicating which of two overlapping collapsed + // spans is larger (and thus includes the other). Falls back to + // comparing ids when the spans cover exactly the same range. + function compareCollapsedMarkers(a, b) { + var lenDiff = a.lines.length - b.lines.length; + if (lenDiff != 0) { return lenDiff } + var aPos = a.find(), bPos = b.find(); + var fromCmp = cmp(aPos.from, bPos.from) || extraLeft(a) - extraLeft(b); + if (fromCmp) { return -fromCmp } + var toCmp = cmp(aPos.to, bPos.to) || extraRight(a) - extraRight(b); + if (toCmp) { return toCmp } + return b.id - a.id + } + + // Find out whether a line ends or starts in a collapsed span. If + // so, return the marker for that span. + function collapsedSpanAtSide(line, start) { + var sps = sawCollapsedSpans && line.markedSpans, found; + if (sps) { for (var sp = (void 0), i = 0; i < sps.length; ++i) { + sp = sps[i]; + if (sp.marker.collapsed && (start ? sp.from : sp.to) == null && + (!found || compareCollapsedMarkers(found, sp.marker) < 0)) + { found = sp.marker; } + } } + return found + } + function collapsedSpanAtStart(line) { return collapsedSpanAtSide(line, true) } + function collapsedSpanAtEnd(line) { return collapsedSpanAtSide(line, false) } + + function collapsedSpanAround(line, ch) { + var sps = sawCollapsedSpans && line.markedSpans, found; + if (sps) { for (var i = 0; i < sps.length; ++i) { + var sp = sps[i]; + if (sp.marker.collapsed && (sp.from == null || sp.from < ch) && (sp.to == null || sp.to > ch) && + (!found || compareCollapsedMarkers(found, sp.marker) < 0)) { found = sp.marker; } + } } + return found + } + + // Test whether there exists a collapsed span that partially + // overlaps (covers the start or end, but not both) of a new span. + // Such overlap is not allowed. + function conflictingCollapsedRange(doc, lineNo, from, to, marker) { + var line = getLine(doc, lineNo); + var sps = sawCollapsedSpans && line.markedSpans; + if (sps) { for (var i = 0; i < sps.length; ++i) { + var sp = sps[i]; + if (!sp.marker.collapsed) { continue } + var found = sp.marker.find(0); + var fromCmp = cmp(found.from, from) || extraLeft(sp.marker) - extraLeft(marker); + var toCmp = cmp(found.to, to) || extraRight(sp.marker) - extraRight(marker); + if (fromCmp >= 0 && toCmp <= 0 || fromCmp <= 0 && toCmp >= 0) { continue } + if (fromCmp <= 0 && (sp.marker.inclusiveRight && marker.inclusiveLeft ? cmp(found.to, from) >= 0 : cmp(found.to, from) > 0) || + fromCmp >= 0 && (sp.marker.inclusiveRight && marker.inclusiveLeft ? cmp(found.from, to) <= 0 : cmp(found.from, to) < 0)) + { return true } + } } + } + + // A visual line is a line as drawn on the screen. Folding, for + // example, can cause multiple logical lines to appear on the same + // visual line. This finds the start of the visual line that the + // given line is part of (usually that is the line itself). + function visualLine(line) { + var merged; + while (merged = collapsedSpanAtStart(line)) + { line = merged.find(-1, true).line; } + return line + } + + function visualLineEnd(line) { + var merged; + while (merged = collapsedSpanAtEnd(line)) + { line = merged.find(1, true).line; } + return line + } + + // Returns an array of logical lines that continue the visual line + // started by the argument, or undefined if there are no such lines. + function visualLineContinued(line) { + var merged, lines; + while (merged = collapsedSpanAtEnd(line)) { + line = merged.find(1, true).line + ;(lines || (lines = [])).push(line); + } + return lines + } + + // Get the line number of the start of the visual line that the + // given line number is part of. + function visualLineNo(doc, lineN) { + var line = getLine(doc, lineN), vis = visualLine(line); + if (line == vis) { return lineN } + return lineNo(vis) + } + + // Get the line number of the start of the next visual line after + // the given line. + function visualLineEndNo(doc, lineN) { + if (lineN > doc.lastLine()) { return lineN } + var line = getLine(doc, lineN), merged; + if (!lineIsHidden(doc, line)) { return lineN } + while (merged = collapsedSpanAtEnd(line)) + { line = merged.find(1, true).line; } + return lineNo(line) + 1 + } + + // Compute whether a line is hidden. Lines count as hidden when they + // are part of a visual line that starts with another line, or when + // they are entirely covered by collapsed, non-widget span. + function lineIsHidden(doc, line) { + var sps = sawCollapsedSpans && line.markedSpans; + if (sps) { for (var sp = (void 0), i = 0; i < sps.length; ++i) { + sp = sps[i]; + if (!sp.marker.collapsed) { continue } + if (sp.from == null) { return true } + if (sp.marker.widgetNode) { continue } + if (sp.from == 0 && sp.marker.inclusiveLeft && lineIsHiddenInner(doc, line, sp)) + { return true } + } } + } + function lineIsHiddenInner(doc, line, span) { + if (span.to == null) { + var end = span.marker.find(1, true); + return lineIsHiddenInner(doc, end.line, getMarkedSpanFor(end.line.markedSpans, span.marker)) + } + if (span.marker.inclusiveRight && span.to == line.text.length) + { return true } + for (var sp = (void 0), i = 0; i < line.markedSpans.length; ++i) { + sp = line.markedSpans[i]; + if (sp.marker.collapsed && !sp.marker.widgetNode && sp.from == span.to && + (sp.to == null || sp.to != span.from) && + (sp.marker.inclusiveLeft || span.marker.inclusiveRight) && + lineIsHiddenInner(doc, line, sp)) { return true } + } + } + + // Find the height above the given line. + function heightAtLine(lineObj) { + lineObj = visualLine(lineObj); + + var h = 0, chunk = lineObj.parent; + for (var i = 0; i < chunk.lines.length; ++i) { + var line = chunk.lines[i]; + if (line == lineObj) { break } + else { h += line.height; } + } + for (var p = chunk.parent; p; chunk = p, p = chunk.parent) { + for (var i$1 = 0; i$1 < p.children.length; ++i$1) { + var cur = p.children[i$1]; + if (cur == chunk) { break } + else { h += cur.height; } + } + } + return h + } + + // Compute the character length of a line, taking into account + // collapsed ranges (see markText) that might hide parts, and join + // other lines onto it. + function lineLength(line) { + if (line.height == 0) { return 0 } + var len = line.text.length, merged, cur = line; + while (merged = collapsedSpanAtStart(cur)) { + var found = merged.find(0, true); + cur = found.from.line; + len += found.from.ch - found.to.ch; + } + cur = line; + while (merged = collapsedSpanAtEnd(cur)) { + var found$1 = merged.find(0, true); + len -= cur.text.length - found$1.from.ch; + cur = found$1.to.line; + len += cur.text.length - found$1.to.ch; + } + return len + } + + // Find the longest line in the document. + function findMaxLine(cm) { + var d = cm.display, doc = cm.doc; + d.maxLine = getLine(doc, doc.first); + d.maxLineLength = lineLength(d.maxLine); + d.maxLineChanged = true; + doc.iter(function (line) { + var len = lineLength(line); + if (len > d.maxLineLength) { + d.maxLineLength = len; + d.maxLine = line; + } + }); + } + + // LINE DATA STRUCTURE + + // Line objects. These hold state related to a line, including + // highlighting info (the styles array). + var Line = function(text, markedSpans, estimateHeight) { + this.text = text; + attachMarkedSpans(this, markedSpans); + this.height = estimateHeight ? estimateHeight(this) : 1; + }; + + Line.prototype.lineNo = function () { return lineNo(this) }; + eventMixin(Line); + + // Change the content (text, markers) of a line. Automatically + // invalidates cached information and tries to re-estimate the + // line's height. + function updateLine(line, text, markedSpans, estimateHeight) { + line.text = text; + if (line.stateAfter) { line.stateAfter = null; } + if (line.styles) { line.styles = null; } + if (line.order != null) { line.order = null; } + detachMarkedSpans(line); + attachMarkedSpans(line, markedSpans); + var estHeight = estimateHeight ? estimateHeight(line) : 1; + if (estHeight != line.height) { updateLineHeight(line, estHeight); } + } + + // Detach a line from the document tree and its markers. + function cleanUpLine(line) { + line.parent = null; + detachMarkedSpans(line); + } + + // Convert a style as returned by a mode (either null, or a string + // containing one or more styles) to a CSS style. This is cached, + // and also looks for line-wide styles. + var styleToClassCache = {}, styleToClassCacheWithMode = {}; + function interpretTokenStyle(style, options) { + if (!style || /^\s*$/.test(style)) { return null } + var cache = options.addModeClass ? styleToClassCacheWithMode : styleToClassCache; + return cache[style] || + (cache[style] = style.replace(/\S+/g, "cm-$&")) + } + + // Render the DOM representation of the text of a line. Also builds + // up a 'line map', which points at the DOM nodes that represent + // specific stretches of text, and is used by the measuring code. + // The returned object contains the DOM node, this map, and + // information about line-wide styles that were set by the mode. + function buildLineContent(cm, lineView) { + // The padding-right forces the element to have a 'border', which + // is needed on Webkit to be able to get line-level bounding + // rectangles for it (in measureChar). + var content = eltP("span", null, null, webkit ? "padding-right: .1px" : null); + var builder = {pre: eltP("pre", [content], "CodeMirror-line"), content: content, + col: 0, pos: 0, cm: cm, + trailingSpace: false, + splitSpaces: cm.getOption("lineWrapping")}; + lineView.measure = {}; + + // Iterate over the logical lines that make up this visual line. + for (var i = 0; i <= (lineView.rest ? lineView.rest.length : 0); i++) { + var line = i ? lineView.rest[i - 1] : lineView.line, order = (void 0); + builder.pos = 0; + builder.addToken = buildToken; + // Optionally wire in some hacks into the token-rendering + // algorithm, to deal with browser quirks. + if (hasBadBidiRects(cm.display.measure) && (order = getOrder(line, cm.doc.direction))) + { builder.addToken = buildTokenBadBidi(builder.addToken, order); } + builder.map = []; + var allowFrontierUpdate = lineView != cm.display.externalMeasured && lineNo(line); + insertLineContent(line, builder, getLineStyles(cm, line, allowFrontierUpdate)); + if (line.styleClasses) { + if (line.styleClasses.bgClass) + { builder.bgClass = joinClasses(line.styleClasses.bgClass, builder.bgClass || ""); } + if (line.styleClasses.textClass) + { builder.textClass = joinClasses(line.styleClasses.textClass, builder.textClass || ""); } + } + + // Ensure at least a single node is present, for measuring. + if (builder.map.length == 0) + { builder.map.push(0, 0, builder.content.appendChild(zeroWidthElement(cm.display.measure))); } + + // Store the map and a cache object for the current logical line + if (i == 0) { + lineView.measure.map = builder.map; + lineView.measure.cache = {}; + } else { + (lineView.measure.maps || (lineView.measure.maps = [])).push(builder.map) + ;(lineView.measure.caches || (lineView.measure.caches = [])).push({}); + } + } + + // See issue #2901 + if (webkit) { + var last = builder.content.lastChild; + if (/\bcm-tab\b/.test(last.className) || (last.querySelector && last.querySelector(".cm-tab"))) + { builder.content.className = "cm-tab-wrap-hack"; } + } + + signal(cm, "renderLine", cm, lineView.line, builder.pre); + if (builder.pre.className) + { builder.textClass = joinClasses(builder.pre.className, builder.textClass || ""); } + + return builder + } + + function defaultSpecialCharPlaceholder(ch) { + var token = elt("span", "\u2022", "cm-invalidchar"); + token.title = "\\u" + ch.charCodeAt(0).toString(16); + token.setAttribute("aria-label", token.title); + return token + } + + // Build up the DOM representation for a single token, and add it to + // the line map. Takes care to render special characters separately. + function buildToken(builder, text, style, startStyle, endStyle, css, attributes) { + if (!text) { return } + var displayText = builder.splitSpaces ? splitSpaces(text, builder.trailingSpace) : text; + var special = builder.cm.state.specialChars, mustWrap = false; + var content; + if (!special.test(text)) { + builder.col += text.length; + content = document.createTextNode(displayText); + builder.map.push(builder.pos, builder.pos + text.length, content); + if (ie && ie_version < 9) { mustWrap = true; } + builder.pos += text.length; + } else { + content = document.createDocumentFragment(); + var pos = 0; + while (true) { + special.lastIndex = pos; + var m = special.exec(text); + var skipped = m ? m.index - pos : text.length - pos; + if (skipped) { + var txt = document.createTextNode(displayText.slice(pos, pos + skipped)); + if (ie && ie_version < 9) { content.appendChild(elt("span", [txt])); } + else { content.appendChild(txt); } + builder.map.push(builder.pos, builder.pos + skipped, txt); + builder.col += skipped; + builder.pos += skipped; + } + if (!m) { break } + pos += skipped + 1; + var txt$1 = (void 0); + if (m[0] == "\t") { + var tabSize = builder.cm.options.tabSize, tabWidth = tabSize - builder.col % tabSize; + txt$1 = content.appendChild(elt("span", spaceStr(tabWidth), "cm-tab")); + txt$1.setAttribute("role", "presentation"); + txt$1.setAttribute("cm-text", "\t"); + builder.col += tabWidth; + } else if (m[0] == "\r" || m[0] == "\n") { + txt$1 = content.appendChild(elt("span", m[0] == "\r" ? "\u240d" : "\u2424", "cm-invalidchar")); + txt$1.setAttribute("cm-text", m[0]); + builder.col += 1; + } else { + txt$1 = builder.cm.options.specialCharPlaceholder(m[0]); + txt$1.setAttribute("cm-text", m[0]); + if (ie && ie_version < 9) { content.appendChild(elt("span", [txt$1])); } + else { content.appendChild(txt$1); } + builder.col += 1; + } + builder.map.push(builder.pos, builder.pos + 1, txt$1); + builder.pos++; + } + } + builder.trailingSpace = displayText.charCodeAt(text.length - 1) == 32; + if (style || startStyle || endStyle || mustWrap || css || attributes) { + var fullStyle = style || ""; + if (startStyle) { fullStyle += startStyle; } + if (endStyle) { fullStyle += endStyle; } + var token = elt("span", [content], fullStyle, css); + if (attributes) { + for (var attr in attributes) { if (attributes.hasOwnProperty(attr) && attr != "style" && attr != "class") + { token.setAttribute(attr, attributes[attr]); } } + } + return builder.content.appendChild(token) + } + builder.content.appendChild(content); + } + + // Change some spaces to NBSP to prevent the browser from collapsing + // trailing spaces at the end of a line when rendering text (issue #1362). + function splitSpaces(text, trailingBefore) { + if (text.length > 1 && !/ /.test(text)) { return text } + var spaceBefore = trailingBefore, result = ""; + for (var i = 0; i < text.length; i++) { + var ch = text.charAt(i); + if (ch == " " && spaceBefore && (i == text.length - 1 || text.charCodeAt(i + 1) == 32)) + { ch = "\u00a0"; } + result += ch; + spaceBefore = ch == " "; + } + return result + } + + // Work around nonsense dimensions being reported for stretches of + // right-to-left text. + function buildTokenBadBidi(inner, order) { + return function (builder, text, style, startStyle, endStyle, css, attributes) { + style = style ? style + " cm-force-border" : "cm-force-border"; + var start = builder.pos, end = start + text.length; + for (;;) { + // Find the part that overlaps with the start of this text + var part = (void 0); + for (var i = 0; i < order.length; i++) { + part = order[i]; + if (part.to > start && part.from <= start) { break } + } + if (part.to >= end) { return inner(builder, text, style, startStyle, endStyle, css, attributes) } + inner(builder, text.slice(0, part.to - start), style, startStyle, null, css, attributes); + startStyle = null; + text = text.slice(part.to - start); + start = part.to; + } + } + } + + function buildCollapsedSpan(builder, size, marker, ignoreWidget) { + var widget = !ignoreWidget && marker.widgetNode; + if (widget) { builder.map.push(builder.pos, builder.pos + size, widget); } + if (!ignoreWidget && builder.cm.display.input.needsContentAttribute) { + if (!widget) + { widget = builder.content.appendChild(document.createElement("span")); } + widget.setAttribute("cm-marker", marker.id); + } + if (widget) { + builder.cm.display.input.setUneditable(widget); + builder.content.appendChild(widget); + } + builder.pos += size; + builder.trailingSpace = false; + } + + // Outputs a number of spans to make up a line, taking highlighting + // and marked text into account. + function insertLineContent(line, builder, styles) { + var spans = line.markedSpans, allText = line.text, at = 0; + if (!spans) { + for (var i$1 = 1; i$1 < styles.length; i$1+=2) + { builder.addToken(builder, allText.slice(at, at = styles[i$1]), interpretTokenStyle(styles[i$1+1], builder.cm.options)); } + return + } + + var len = allText.length, pos = 0, i = 1, text = "", style, css; + var nextChange = 0, spanStyle, spanEndStyle, spanStartStyle, collapsed, attributes; + for (;;) { + if (nextChange == pos) { // Update current marker set + spanStyle = spanEndStyle = spanStartStyle = css = ""; + attributes = null; + collapsed = null; nextChange = Infinity; + var foundBookmarks = [], endStyles = (void 0); + for (var j = 0; j < spans.length; ++j) { + var sp = spans[j], m = sp.marker; + if (m.type == "bookmark" && sp.from == pos && m.widgetNode) { + foundBookmarks.push(m); + } else if (sp.from <= pos && (sp.to == null || sp.to > pos || m.collapsed && sp.to == pos && sp.from == pos)) { + if (sp.to != null && sp.to != pos && nextChange > sp.to) { + nextChange = sp.to; + spanEndStyle = ""; + } + if (m.className) { spanStyle += " " + m.className; } + if (m.css) { css = (css ? css + ";" : "") + m.css; } + if (m.startStyle && sp.from == pos) { spanStartStyle += " " + m.startStyle; } + if (m.endStyle && sp.to == nextChange) { (endStyles || (endStyles = [])).push(m.endStyle, sp.to); } + // support for the old title property + // https://github.com/codemirror/CodeMirror/pull/5673 + if (m.title) { (attributes || (attributes = {})).title = m.title; } + if (m.attributes) { + for (var attr in m.attributes) + { (attributes || (attributes = {}))[attr] = m.attributes[attr]; } + } + if (m.collapsed && (!collapsed || compareCollapsedMarkers(collapsed.marker, m) < 0)) + { collapsed = sp; } + } else if (sp.from > pos && nextChange > sp.from) { + nextChange = sp.from; + } + } + if (endStyles) { for (var j$1 = 0; j$1 < endStyles.length; j$1 += 2) + { if (endStyles[j$1 + 1] == nextChange) { spanEndStyle += " " + endStyles[j$1]; } } } + + if (!collapsed || collapsed.from == pos) { for (var j$2 = 0; j$2 < foundBookmarks.length; ++j$2) + { buildCollapsedSpan(builder, 0, foundBookmarks[j$2]); } } + if (collapsed && (collapsed.from || 0) == pos) { + buildCollapsedSpan(builder, (collapsed.to == null ? len + 1 : collapsed.to) - pos, + collapsed.marker, collapsed.from == null); + if (collapsed.to == null) { return } + if (collapsed.to == pos) { collapsed = false; } + } + } + if (pos >= len) { break } + + var upto = Math.min(len, nextChange); + while (true) { + if (text) { + var end = pos + text.length; + if (!collapsed) { + var tokenText = end > upto ? text.slice(0, upto - pos) : text; + builder.addToken(builder, tokenText, style ? style + spanStyle : spanStyle, + spanStartStyle, pos + tokenText.length == nextChange ? spanEndStyle : "", css, attributes); + } + if (end >= upto) {text = text.slice(upto - pos); pos = upto; break} + pos = end; + spanStartStyle = ""; + } + text = allText.slice(at, at = styles[i++]); + style = interpretTokenStyle(styles[i++], builder.cm.options); + } + } + } + + + // These objects are used to represent the visible (currently drawn) + // part of the document. A LineView may correspond to multiple + // logical lines, if those are connected by collapsed ranges. + function LineView(doc, line, lineN) { + // The starting line + this.line = line; + // Continuing lines, if any + this.rest = visualLineContinued(line); + // Number of logical lines in this visual line + this.size = this.rest ? lineNo(lst(this.rest)) - lineN + 1 : 1; + this.node = this.text = null; + this.hidden = lineIsHidden(doc, line); + } + + // Create a range of LineView objects for the given lines. + function buildViewArray(cm, from, to) { + var array = [], nextPos; + for (var pos = from; pos < to; pos = nextPos) { + var view = new LineView(cm.doc, getLine(cm.doc, pos), pos); + nextPos = pos + view.size; + array.push(view); + } + return array + } + + var operationGroup = null; + + function pushOperation(op) { + if (operationGroup) { + operationGroup.ops.push(op); + } else { + op.ownsGroup = operationGroup = { + ops: [op], + delayedCallbacks: [] + }; + } + } + + function fireCallbacksForOps(group) { + // Calls delayed callbacks and cursorActivity handlers until no + // new ones appear + var callbacks = group.delayedCallbacks, i = 0; + do { + for (; i < callbacks.length; i++) + { callbacks[i].call(null); } + for (var j = 0; j < group.ops.length; j++) { + var op = group.ops[j]; + if (op.cursorActivityHandlers) + { while (op.cursorActivityCalled < op.cursorActivityHandlers.length) + { op.cursorActivityHandlers[op.cursorActivityCalled++].call(null, op.cm); } } + } + } while (i < callbacks.length) + } + + function finishOperation(op, endCb) { + var group = op.ownsGroup; + if (!group) { return } + + try { fireCallbacksForOps(group); } + finally { + operationGroup = null; + endCb(group); + } + } + + var orphanDelayedCallbacks = null; + + // Often, we want to signal events at a point where we are in the + // middle of some work, but don't want the handler to start calling + // other methods on the editor, which might be in an inconsistent + // state or simply not expect any other events to happen. + // signalLater looks whether there are any handlers, and schedules + // them to be executed when the last operation ends, or, if no + // operation is active, when a timeout fires. + function signalLater(emitter, type /*, values...*/) { + var arr = getHandlers(emitter, type); + if (!arr.length) { return } + var args = Array.prototype.slice.call(arguments, 2), list; + if (operationGroup) { + list = operationGroup.delayedCallbacks; + } else if (orphanDelayedCallbacks) { + list = orphanDelayedCallbacks; + } else { + list = orphanDelayedCallbacks = []; + setTimeout(fireOrphanDelayed, 0); + } + var loop = function ( i ) { + list.push(function () { return arr[i].apply(null, args); }); + }; + + for (var i = 0; i < arr.length; ++i) + loop( i ); + } + + function fireOrphanDelayed() { + var delayed = orphanDelayedCallbacks; + orphanDelayedCallbacks = null; + for (var i = 0; i < delayed.length; ++i) { delayed[i](); } + } + + // When an aspect of a line changes, a string is added to + // lineView.changes. This updates the relevant part of the line's + // DOM structure. + function updateLineForChanges(cm, lineView, lineN, dims) { + for (var j = 0; j < lineView.changes.length; j++) { + var type = lineView.changes[j]; + if (type == "text") { updateLineText(cm, lineView); } + else if (type == "gutter") { updateLineGutter(cm, lineView, lineN, dims); } + else if (type == "class") { updateLineClasses(cm, lineView); } + else if (type == "widget") { updateLineWidgets(cm, lineView, dims); } + } + lineView.changes = null; + } + + // Lines with gutter elements, widgets or a background class need to + // be wrapped, and have the extra elements added to the wrapper div + function ensureLineWrapped(lineView) { + if (lineView.node == lineView.text) { + lineView.node = elt("div", null, null, "position: relative"); + if (lineView.text.parentNode) + { lineView.text.parentNode.replaceChild(lineView.node, lineView.text); } + lineView.node.appendChild(lineView.text); + if (ie && ie_version < 8) { lineView.node.style.zIndex = 2; } + } + return lineView.node + } + + function updateLineBackground(cm, lineView) { + var cls = lineView.bgClass ? lineView.bgClass + " " + (lineView.line.bgClass || "") : lineView.line.bgClass; + if (cls) { cls += " CodeMirror-linebackground"; } + if (lineView.background) { + if (cls) { lineView.background.className = cls; } + else { lineView.background.parentNode.removeChild(lineView.background); lineView.background = null; } + } else if (cls) { + var wrap = ensureLineWrapped(lineView); + lineView.background = wrap.insertBefore(elt("div", null, cls), wrap.firstChild); + cm.display.input.setUneditable(lineView.background); + } + } + + // Wrapper around buildLineContent which will reuse the structure + // in display.externalMeasured when possible. + function getLineContent(cm, lineView) { + var ext = cm.display.externalMeasured; + if (ext && ext.line == lineView.line) { + cm.display.externalMeasured = null; + lineView.measure = ext.measure; + return ext.built + } + return buildLineContent(cm, lineView) + } + + // Redraw the line's text. Interacts with the background and text + // classes because the mode may output tokens that influence these + // classes. + function updateLineText(cm, lineView) { + var cls = lineView.text.className; + var built = getLineContent(cm, lineView); + if (lineView.text == lineView.node) { lineView.node = built.pre; } + lineView.text.parentNode.replaceChild(built.pre, lineView.text); + lineView.text = built.pre; + if (built.bgClass != lineView.bgClass || built.textClass != lineView.textClass) { + lineView.bgClass = built.bgClass; + lineView.textClass = built.textClass; + updateLineClasses(cm, lineView); + } else if (cls) { + lineView.text.className = cls; + } + } + + function updateLineClasses(cm, lineView) { + updateLineBackground(cm, lineView); + if (lineView.line.wrapClass) + { ensureLineWrapped(lineView).className = lineView.line.wrapClass; } + else if (lineView.node != lineView.text) + { lineView.node.className = ""; } + var textClass = lineView.textClass ? lineView.textClass + " " + (lineView.line.textClass || "") : lineView.line.textClass; + lineView.text.className = textClass || ""; + } + + function updateLineGutter(cm, lineView, lineN, dims) { + if (lineView.gutter) { + lineView.node.removeChild(lineView.gutter); + lineView.gutter = null; + } + if (lineView.gutterBackground) { + lineView.node.removeChild(lineView.gutterBackground); + lineView.gutterBackground = null; + } + if (lineView.line.gutterClass) { + var wrap = ensureLineWrapped(lineView); + lineView.gutterBackground = elt("div", null, "CodeMirror-gutter-background " + lineView.line.gutterClass, + ("left: " + (cm.options.fixedGutter ? dims.fixedPos : -dims.gutterTotalWidth) + "px; width: " + (dims.gutterTotalWidth) + "px")); + cm.display.input.setUneditable(lineView.gutterBackground); + wrap.insertBefore(lineView.gutterBackground, lineView.text); + } + var markers = lineView.line.gutterMarkers; + if (cm.options.lineNumbers || markers) { + var wrap$1 = ensureLineWrapped(lineView); + var gutterWrap = lineView.gutter = elt("div", null, "CodeMirror-gutter-wrapper", ("left: " + (cm.options.fixedGutter ? dims.fixedPos : -dims.gutterTotalWidth) + "px")); + gutterWrap.setAttribute("aria-hidden", "true"); + cm.display.input.setUneditable(gutterWrap); + wrap$1.insertBefore(gutterWrap, lineView.text); + if (lineView.line.gutterClass) + { gutterWrap.className += " " + lineView.line.gutterClass; } + if (cm.options.lineNumbers && (!markers || !markers["CodeMirror-linenumbers"])) + { lineView.lineNumber = gutterWrap.appendChild( + elt("div", lineNumberFor(cm.options, lineN), + "CodeMirror-linenumber CodeMirror-gutter-elt", + ("left: " + (dims.gutterLeft["CodeMirror-linenumbers"]) + "px; width: " + (cm.display.lineNumInnerWidth) + "px"))); } + if (markers) { for (var k = 0; k < cm.display.gutterSpecs.length; ++k) { + var id = cm.display.gutterSpecs[k].className, found = markers.hasOwnProperty(id) && markers[id]; + if (found) + { gutterWrap.appendChild(elt("div", [found], "CodeMirror-gutter-elt", + ("left: " + (dims.gutterLeft[id]) + "px; width: " + (dims.gutterWidth[id]) + "px"))); } + } } + } + } + + function updateLineWidgets(cm, lineView, dims) { + if (lineView.alignable) { lineView.alignable = null; } + var isWidget = classTest("CodeMirror-linewidget"); + for (var node = lineView.node.firstChild, next = (void 0); node; node = next) { + next = node.nextSibling; + if (isWidget.test(node.className)) { lineView.node.removeChild(node); } + } + insertLineWidgets(cm, lineView, dims); + } + + // Build a line's DOM representation from scratch + function buildLineElement(cm, lineView, lineN, dims) { + var built = getLineContent(cm, lineView); + lineView.text = lineView.node = built.pre; + if (built.bgClass) { lineView.bgClass = built.bgClass; } + if (built.textClass) { lineView.textClass = built.textClass; } + + updateLineClasses(cm, lineView); + updateLineGutter(cm, lineView, lineN, dims); + insertLineWidgets(cm, lineView, dims); + return lineView.node + } + + // A lineView may contain multiple logical lines (when merged by + // collapsed spans). The widgets for all of them need to be drawn. + function insertLineWidgets(cm, lineView, dims) { + insertLineWidgetsFor(cm, lineView.line, lineView, dims, true); + if (lineView.rest) { for (var i = 0; i < lineView.rest.length; i++) + { insertLineWidgetsFor(cm, lineView.rest[i], lineView, dims, false); } } + } + + function insertLineWidgetsFor(cm, line, lineView, dims, allowAbove) { + if (!line.widgets) { return } + var wrap = ensureLineWrapped(lineView); + for (var i = 0, ws = line.widgets; i < ws.length; ++i) { + var widget = ws[i], node = elt("div", [widget.node], "CodeMirror-linewidget" + (widget.className ? " " + widget.className : "")); + if (!widget.handleMouseEvents) { node.setAttribute("cm-ignore-events", "true"); } + positionLineWidget(widget, node, lineView, dims); + cm.display.input.setUneditable(node); + if (allowAbove && widget.above) + { wrap.insertBefore(node, lineView.gutter || lineView.text); } + else + { wrap.appendChild(node); } + signalLater(widget, "redraw"); + } + } + + function positionLineWidget(widget, node, lineView, dims) { + if (widget.noHScroll) { + (lineView.alignable || (lineView.alignable = [])).push(node); + var width = dims.wrapperWidth; + node.style.left = dims.fixedPos + "px"; + if (!widget.coverGutter) { + width -= dims.gutterTotalWidth; + node.style.paddingLeft = dims.gutterTotalWidth + "px"; + } + node.style.width = width + "px"; + } + if (widget.coverGutter) { + node.style.zIndex = 5; + node.style.position = "relative"; + if (!widget.noHScroll) { node.style.marginLeft = -dims.gutterTotalWidth + "px"; } + } + } + + function widgetHeight(widget) { + if (widget.height != null) { return widget.height } + var cm = widget.doc.cm; + if (!cm) { return 0 } + if (!contains(document.body, widget.node)) { + var parentStyle = "position: relative;"; + if (widget.coverGutter) + { parentStyle += "margin-left: -" + cm.display.gutters.offsetWidth + "px;"; } + if (widget.noHScroll) + { parentStyle += "width: " + cm.display.wrapper.clientWidth + "px;"; } + removeChildrenAndAdd(cm.display.measure, elt("div", [widget.node], null, parentStyle)); + } + return widget.height = widget.node.parentNode.offsetHeight + } + + // Return true when the given mouse event happened in a widget + function eventInWidget(display, e) { + for (var n = e_target(e); n != display.wrapper; n = n.parentNode) { + if (!n || (n.nodeType == 1 && n.getAttribute("cm-ignore-events") == "true") || + (n.parentNode == display.sizer && n != display.mover)) + { return true } + } + } + + // POSITION MEASUREMENT + + function paddingTop(display) {return display.lineSpace.offsetTop} + function paddingVert(display) {return display.mover.offsetHeight - display.lineSpace.offsetHeight} + function paddingH(display) { + if (display.cachedPaddingH) { return display.cachedPaddingH } + var e = removeChildrenAndAdd(display.measure, elt("pre", "x", "CodeMirror-line-like")); + var style = window.getComputedStyle ? window.getComputedStyle(e) : e.currentStyle; + var data = {left: parseInt(style.paddingLeft), right: parseInt(style.paddingRight)}; + if (!isNaN(data.left) && !isNaN(data.right)) { display.cachedPaddingH = data; } + return data + } + + function scrollGap(cm) { return scrollerGap - cm.display.nativeBarWidth } + function displayWidth(cm) { + return cm.display.scroller.clientWidth - scrollGap(cm) - cm.display.barWidth + } + function displayHeight(cm) { + return cm.display.scroller.clientHeight - scrollGap(cm) - cm.display.barHeight + } + + // Ensure the lineView.wrapping.heights array is populated. This is + // an array of bottom offsets for the lines that make up a drawn + // line. When lineWrapping is on, there might be more than one + // height. + function ensureLineHeights(cm, lineView, rect) { + var wrapping = cm.options.lineWrapping; + var curWidth = wrapping && displayWidth(cm); + if (!lineView.measure.heights || wrapping && lineView.measure.width != curWidth) { + var heights = lineView.measure.heights = []; + if (wrapping) { + lineView.measure.width = curWidth; + var rects = lineView.text.firstChild.getClientRects(); + for (var i = 0; i < rects.length - 1; i++) { + var cur = rects[i], next = rects[i + 1]; + if (Math.abs(cur.bottom - next.bottom) > 2) + { heights.push((cur.bottom + next.top) / 2 - rect.top); } + } + } + heights.push(rect.bottom - rect.top); + } + } + + // Find a line map (mapping character offsets to text nodes) and a + // measurement cache for the given line number. (A line view might + // contain multiple lines when collapsed ranges are present.) + function mapFromLineView(lineView, line, lineN) { + if (lineView.line == line) + { return {map: lineView.measure.map, cache: lineView.measure.cache} } + if (lineView.rest) { + for (var i = 0; i < lineView.rest.length; i++) + { if (lineView.rest[i] == line) + { return {map: lineView.measure.maps[i], cache: lineView.measure.caches[i]} } } + for (var i$1 = 0; i$1 < lineView.rest.length; i$1++) + { if (lineNo(lineView.rest[i$1]) > lineN) + { return {map: lineView.measure.maps[i$1], cache: lineView.measure.caches[i$1], before: true} } } + } + } + + // Render a line into the hidden node display.externalMeasured. Used + // when measurement is needed for a line that's not in the viewport. + function updateExternalMeasurement(cm, line) { + line = visualLine(line); + var lineN = lineNo(line); + var view = cm.display.externalMeasured = new LineView(cm.doc, line, lineN); + view.lineN = lineN; + var built = view.built = buildLineContent(cm, view); + view.text = built.pre; + removeChildrenAndAdd(cm.display.lineMeasure, built.pre); + return view + } + + // Get a {top, bottom, left, right} box (in line-local coordinates) + // for a given character. + function measureChar(cm, line, ch, bias) { + return measureCharPrepared(cm, prepareMeasureForLine(cm, line), ch, bias) + } + + // Find a line view that corresponds to the given line number. + function findViewForLine(cm, lineN) { + if (lineN >= cm.display.viewFrom && lineN < cm.display.viewTo) + { return cm.display.view[findViewIndex(cm, lineN)] } + var ext = cm.display.externalMeasured; + if (ext && lineN >= ext.lineN && lineN < ext.lineN + ext.size) + { return ext } + } + + // Measurement can be split in two steps, the set-up work that + // applies to the whole line, and the measurement of the actual + // character. Functions like coordsChar, that need to do a lot of + // measurements in a row, can thus ensure that the set-up work is + // only done once. + function prepareMeasureForLine(cm, line) { + var lineN = lineNo(line); + var view = findViewForLine(cm, lineN); + if (view && !view.text) { + view = null; + } else if (view && view.changes) { + updateLineForChanges(cm, view, lineN, getDimensions(cm)); + cm.curOp.forceUpdate = true; + } + if (!view) + { view = updateExternalMeasurement(cm, line); } + + var info = mapFromLineView(view, line, lineN); + return { + line: line, view: view, rect: null, + map: info.map, cache: info.cache, before: info.before, + hasHeights: false + } + } + + // Given a prepared measurement object, measures the position of an + // actual character (or fetches it from the cache). + function measureCharPrepared(cm, prepared, ch, bias, varHeight) { + if (prepared.before) { ch = -1; } + var key = ch + (bias || ""), found; + if (prepared.cache.hasOwnProperty(key)) { + found = prepared.cache[key]; + } else { + if (!prepared.rect) + { prepared.rect = prepared.view.text.getBoundingClientRect(); } + if (!prepared.hasHeights) { + ensureLineHeights(cm, prepared.view, prepared.rect); + prepared.hasHeights = true; + } + found = measureCharInner(cm, prepared, ch, bias); + if (!found.bogus) { prepared.cache[key] = found; } + } + return {left: found.left, right: found.right, + top: varHeight ? found.rtop : found.top, + bottom: varHeight ? found.rbottom : found.bottom} + } + + var nullRect = {left: 0, right: 0, top: 0, bottom: 0}; + + function nodeAndOffsetInLineMap(map, ch, bias) { + var node, start, end, collapse, mStart, mEnd; + // First, search the line map for the text node corresponding to, + // or closest to, the target character. + for (var i = 0; i < map.length; i += 3) { + mStart = map[i]; + mEnd = map[i + 1]; + if (ch < mStart) { + start = 0; end = 1; + collapse = "left"; + } else if (ch < mEnd) { + start = ch - mStart; + end = start + 1; + } else if (i == map.length - 3 || ch == mEnd && map[i + 3] > ch) { + end = mEnd - mStart; + start = end - 1; + if (ch >= mEnd) { collapse = "right"; } + } + if (start != null) { + node = map[i + 2]; + if (mStart == mEnd && bias == (node.insertLeft ? "left" : "right")) + { collapse = bias; } + if (bias == "left" && start == 0) + { while (i && map[i - 2] == map[i - 3] && map[i - 1].insertLeft) { + node = map[(i -= 3) + 2]; + collapse = "left"; + } } + if (bias == "right" && start == mEnd - mStart) + { while (i < map.length - 3 && map[i + 3] == map[i + 4] && !map[i + 5].insertLeft) { + node = map[(i += 3) + 2]; + collapse = "right"; + } } + break + } + } + return {node: node, start: start, end: end, collapse: collapse, coverStart: mStart, coverEnd: mEnd} + } + + function getUsefulRect(rects, bias) { + var rect = nullRect; + if (bias == "left") { for (var i = 0; i < rects.length; i++) { + if ((rect = rects[i]).left != rect.right) { break } + } } else { for (var i$1 = rects.length - 1; i$1 >= 0; i$1--) { + if ((rect = rects[i$1]).left != rect.right) { break } + } } + return rect + } + + function measureCharInner(cm, prepared, ch, bias) { + var place = nodeAndOffsetInLineMap(prepared.map, ch, bias); + var node = place.node, start = place.start, end = place.end, collapse = place.collapse; + + var rect; + if (node.nodeType == 3) { // If it is a text node, use a range to retrieve the coordinates. + for (var i$1 = 0; i$1 < 4; i$1++) { // Retry a maximum of 4 times when nonsense rectangles are returned + while (start && isExtendingChar(prepared.line.text.charAt(place.coverStart + start))) { --start; } + while (place.coverStart + end < place.coverEnd && isExtendingChar(prepared.line.text.charAt(place.coverStart + end))) { ++end; } + if (ie && ie_version < 9 && start == 0 && end == place.coverEnd - place.coverStart) + { rect = node.parentNode.getBoundingClientRect(); } + else + { rect = getUsefulRect(range(node, start, end).getClientRects(), bias); } + if (rect.left || rect.right || start == 0) { break } + end = start; + start = start - 1; + collapse = "right"; + } + if (ie && ie_version < 11) { rect = maybeUpdateRectForZooming(cm.display.measure, rect); } + } else { // If it is a widget, simply get the box for the whole widget. + if (start > 0) { collapse = bias = "right"; } + var rects; + if (cm.options.lineWrapping && (rects = node.getClientRects()).length > 1) + { rect = rects[bias == "right" ? rects.length - 1 : 0]; } + else + { rect = node.getBoundingClientRect(); } + } + if (ie && ie_version < 9 && !start && (!rect || !rect.left && !rect.right)) { + var rSpan = node.parentNode.getClientRects()[0]; + if (rSpan) + { rect = {left: rSpan.left, right: rSpan.left + charWidth(cm.display), top: rSpan.top, bottom: rSpan.bottom}; } + else + { rect = nullRect; } + } + + var rtop = rect.top - prepared.rect.top, rbot = rect.bottom - prepared.rect.top; + var mid = (rtop + rbot) / 2; + var heights = prepared.view.measure.heights; + var i = 0; + for (; i < heights.length - 1; i++) + { if (mid < heights[i]) { break } } + var top = i ? heights[i - 1] : 0, bot = heights[i]; + var result = {left: (collapse == "right" ? rect.right : rect.left) - prepared.rect.left, + right: (collapse == "left" ? rect.left : rect.right) - prepared.rect.left, + top: top, bottom: bot}; + if (!rect.left && !rect.right) { result.bogus = true; } + if (!cm.options.singleCursorHeightPerLine) { result.rtop = rtop; result.rbottom = rbot; } + + return result + } + + // Work around problem with bounding client rects on ranges being + // returned incorrectly when zoomed on IE10 and below. + function maybeUpdateRectForZooming(measure, rect) { + if (!window.screen || screen.logicalXDPI == null || + screen.logicalXDPI == screen.deviceXDPI || !hasBadZoomedRects(measure)) + { return rect } + var scaleX = screen.logicalXDPI / screen.deviceXDPI; + var scaleY = screen.logicalYDPI / screen.deviceYDPI; + return {left: rect.left * scaleX, right: rect.right * scaleX, + top: rect.top * scaleY, bottom: rect.bottom * scaleY} + } + + function clearLineMeasurementCacheFor(lineView) { + if (lineView.measure) { + lineView.measure.cache = {}; + lineView.measure.heights = null; + if (lineView.rest) { for (var i = 0; i < lineView.rest.length; i++) + { lineView.measure.caches[i] = {}; } } + } + } + + function clearLineMeasurementCache(cm) { + cm.display.externalMeasure = null; + removeChildren(cm.display.lineMeasure); + for (var i = 0; i < cm.display.view.length; i++) + { clearLineMeasurementCacheFor(cm.display.view[i]); } + } + + function clearCaches(cm) { + clearLineMeasurementCache(cm); + cm.display.cachedCharWidth = cm.display.cachedTextHeight = cm.display.cachedPaddingH = null; + if (!cm.options.lineWrapping) { cm.display.maxLineChanged = true; } + cm.display.lineNumChars = null; + } + + function pageScrollX(doc) { + // Work around https://bugs.chromium.org/p/chromium/issues/detail?id=489206 + // which causes page_Offset and bounding client rects to use + // different reference viewports and invalidate our calculations. + if (chrome && android) { return -(doc.body.getBoundingClientRect().left - parseInt(getComputedStyle(doc.body).marginLeft)) } + return doc.defaultView.pageXOffset || (doc.documentElement || doc.body).scrollLeft + } + function pageScrollY(doc) { + if (chrome && android) { return -(doc.body.getBoundingClientRect().top - parseInt(getComputedStyle(doc.body).marginTop)) } + return doc.defaultView.pageYOffset || (doc.documentElement || doc.body).scrollTop + } + + function widgetTopHeight(lineObj) { + var ref = visualLine(lineObj); + var widgets = ref.widgets; + var height = 0; + if (widgets) { for (var i = 0; i < widgets.length; ++i) { if (widgets[i].above) + { height += widgetHeight(widgets[i]); } } } + return height + } + + // Converts a {top, bottom, left, right} box from line-local + // coordinates into another coordinate system. Context may be one of + // "line", "div" (display.lineDiv), "local"./null (editor), "window", + // or "page". + function intoCoordSystem(cm, lineObj, rect, context, includeWidgets) { + if (!includeWidgets) { + var height = widgetTopHeight(lineObj); + rect.top += height; rect.bottom += height; + } + if (context == "line") { return rect } + if (!context) { context = "local"; } + var yOff = heightAtLine(lineObj); + if (context == "local") { yOff += paddingTop(cm.display); } + else { yOff -= cm.display.viewOffset; } + if (context == "page" || context == "window") { + var lOff = cm.display.lineSpace.getBoundingClientRect(); + yOff += lOff.top + (context == "window" ? 0 : pageScrollY(doc(cm))); + var xOff = lOff.left + (context == "window" ? 0 : pageScrollX(doc(cm))); + rect.left += xOff; rect.right += xOff; + } + rect.top += yOff; rect.bottom += yOff; + return rect + } + + // Coverts a box from "div" coords to another coordinate system. + // Context may be "window", "page", "div", or "local"./null. + function fromCoordSystem(cm, coords, context) { + if (context == "div") { return coords } + var left = coords.left, top = coords.top; + // First move into "page" coordinate system + if (context == "page") { + left -= pageScrollX(doc(cm)); + top -= pageScrollY(doc(cm)); + } else if (context == "local" || !context) { + var localBox = cm.display.sizer.getBoundingClientRect(); + left += localBox.left; + top += localBox.top; + } + + var lineSpaceBox = cm.display.lineSpace.getBoundingClientRect(); + return {left: left - lineSpaceBox.left, top: top - lineSpaceBox.top} + } + + function charCoords(cm, pos, context, lineObj, bias) { + if (!lineObj) { lineObj = getLine(cm.doc, pos.line); } + return intoCoordSystem(cm, lineObj, measureChar(cm, lineObj, pos.ch, bias), context) + } + + // Returns a box for a given cursor position, which may have an + // 'other' property containing the position of the secondary cursor + // on a bidi boundary. + // A cursor Pos(line, char, "before") is on the same visual line as `char - 1` + // and after `char - 1` in writing order of `char - 1` + // A cursor Pos(line, char, "after") is on the same visual line as `char` + // and before `char` in writing order of `char` + // Examples (upper-case letters are RTL, lower-case are LTR): + // Pos(0, 1, ...) + // before after + // ab a|b a|b + // aB a|B aB| + // Ab |Ab A|b + // AB B|A B|A + // Every position after the last character on a line is considered to stick + // to the last character on the line. + function cursorCoords(cm, pos, context, lineObj, preparedMeasure, varHeight) { + lineObj = lineObj || getLine(cm.doc, pos.line); + if (!preparedMeasure) { preparedMeasure = prepareMeasureForLine(cm, lineObj); } + function get(ch, right) { + var m = measureCharPrepared(cm, preparedMeasure, ch, right ? "right" : "left", varHeight); + if (right) { m.left = m.right; } else { m.right = m.left; } + return intoCoordSystem(cm, lineObj, m, context) + } + var order = getOrder(lineObj, cm.doc.direction), ch = pos.ch, sticky = pos.sticky; + if (ch >= lineObj.text.length) { + ch = lineObj.text.length; + sticky = "before"; + } else if (ch <= 0) { + ch = 0; + sticky = "after"; + } + if (!order) { return get(sticky == "before" ? ch - 1 : ch, sticky == "before") } + + function getBidi(ch, partPos, invert) { + var part = order[partPos], right = part.level == 1; + return get(invert ? ch - 1 : ch, right != invert) + } + var partPos = getBidiPartAt(order, ch, sticky); + var other = bidiOther; + var val = getBidi(ch, partPos, sticky == "before"); + if (other != null) { val.other = getBidi(ch, other, sticky != "before"); } + return val + } + + // Used to cheaply estimate the coordinates for a position. Used for + // intermediate scroll updates. + function estimateCoords(cm, pos) { + var left = 0; + pos = clipPos(cm.doc, pos); + if (!cm.options.lineWrapping) { left = charWidth(cm.display) * pos.ch; } + var lineObj = getLine(cm.doc, pos.line); + var top = heightAtLine(lineObj) + paddingTop(cm.display); + return {left: left, right: left, top: top, bottom: top + lineObj.height} + } + + // Positions returned by coordsChar contain some extra information. + // xRel is the relative x position of the input coordinates compared + // to the found position (so xRel > 0 means the coordinates are to + // the right of the character position, for example). When outside + // is true, that means the coordinates lie outside the line's + // vertical range. + function PosWithInfo(line, ch, sticky, outside, xRel) { + var pos = Pos(line, ch, sticky); + pos.xRel = xRel; + if (outside) { pos.outside = outside; } + return pos + } + + // Compute the character position closest to the given coordinates. + // Input must be lineSpace-local ("div" coordinate system). + function coordsChar(cm, x, y) { + var doc = cm.doc; + y += cm.display.viewOffset; + if (y < 0) { return PosWithInfo(doc.first, 0, null, -1, -1) } + var lineN = lineAtHeight(doc, y), last = doc.first + doc.size - 1; + if (lineN > last) + { return PosWithInfo(doc.first + doc.size - 1, getLine(doc, last).text.length, null, 1, 1) } + if (x < 0) { x = 0; } + + var lineObj = getLine(doc, lineN); + for (;;) { + var found = coordsCharInner(cm, lineObj, lineN, x, y); + var collapsed = collapsedSpanAround(lineObj, found.ch + (found.xRel > 0 || found.outside > 0 ? 1 : 0)); + if (!collapsed) { return found } + var rangeEnd = collapsed.find(1); + if (rangeEnd.line == lineN) { return rangeEnd } + lineObj = getLine(doc, lineN = rangeEnd.line); + } + } + + function wrappedLineExtent(cm, lineObj, preparedMeasure, y) { + y -= widgetTopHeight(lineObj); + var end = lineObj.text.length; + var begin = findFirst(function (ch) { return measureCharPrepared(cm, preparedMeasure, ch - 1).bottom <= y; }, end, 0); + end = findFirst(function (ch) { return measureCharPrepared(cm, preparedMeasure, ch).top > y; }, begin, end); + return {begin: begin, end: end} + } + + function wrappedLineExtentChar(cm, lineObj, preparedMeasure, target) { + if (!preparedMeasure) { preparedMeasure = prepareMeasureForLine(cm, lineObj); } + var targetTop = intoCoordSystem(cm, lineObj, measureCharPrepared(cm, preparedMeasure, target), "line").top; + return wrappedLineExtent(cm, lineObj, preparedMeasure, targetTop) + } + + // Returns true if the given side of a box is after the given + // coordinates, in top-to-bottom, left-to-right order. + function boxIsAfter(box, x, y, left) { + return box.bottom <= y ? false : box.top > y ? true : (left ? box.left : box.right) > x + } + + function coordsCharInner(cm, lineObj, lineNo, x, y) { + // Move y into line-local coordinate space + y -= heightAtLine(lineObj); + var preparedMeasure = prepareMeasureForLine(cm, lineObj); + // When directly calling `measureCharPrepared`, we have to adjust + // for the widgets at this line. + var widgetHeight = widgetTopHeight(lineObj); + var begin = 0, end = lineObj.text.length, ltr = true; + + var order = getOrder(lineObj, cm.doc.direction); + // If the line isn't plain left-to-right text, first figure out + // which bidi section the coordinates fall into. + if (order) { + var part = (cm.options.lineWrapping ? coordsBidiPartWrapped : coordsBidiPart) + (cm, lineObj, lineNo, preparedMeasure, order, x, y); + ltr = part.level != 1; + // The awkward -1 offsets are needed because findFirst (called + // on these below) will treat its first bound as inclusive, + // second as exclusive, but we want to actually address the + // characters in the part's range + begin = ltr ? part.from : part.to - 1; + end = ltr ? part.to : part.from - 1; + } + + // A binary search to find the first character whose bounding box + // starts after the coordinates. If we run across any whose box wrap + // the coordinates, store that. + var chAround = null, boxAround = null; + var ch = findFirst(function (ch) { + var box = measureCharPrepared(cm, preparedMeasure, ch); + box.top += widgetHeight; box.bottom += widgetHeight; + if (!boxIsAfter(box, x, y, false)) { return false } + if (box.top <= y && box.left <= x) { + chAround = ch; + boxAround = box; + } + return true + }, begin, end); + + var baseX, sticky, outside = false; + // If a box around the coordinates was found, use that + if (boxAround) { + // Distinguish coordinates nearer to the left or right side of the box + var atLeft = x - boxAround.left < boxAround.right - x, atStart = atLeft == ltr; + ch = chAround + (atStart ? 0 : 1); + sticky = atStart ? "after" : "before"; + baseX = atLeft ? boxAround.left : boxAround.right; + } else { + // (Adjust for extended bound, if necessary.) + if (!ltr && (ch == end || ch == begin)) { ch++; } + // To determine which side to associate with, get the box to the + // left of the character and compare it's vertical position to the + // coordinates + sticky = ch == 0 ? "after" : ch == lineObj.text.length ? "before" : + (measureCharPrepared(cm, preparedMeasure, ch - (ltr ? 1 : 0)).bottom + widgetHeight <= y) == ltr ? + "after" : "before"; + // Now get accurate coordinates for this place, in order to get a + // base X position + var coords = cursorCoords(cm, Pos(lineNo, ch, sticky), "line", lineObj, preparedMeasure); + baseX = coords.left; + outside = y < coords.top ? -1 : y >= coords.bottom ? 1 : 0; + } + + ch = skipExtendingChars(lineObj.text, ch, 1); + return PosWithInfo(lineNo, ch, sticky, outside, x - baseX) + } + + function coordsBidiPart(cm, lineObj, lineNo, preparedMeasure, order, x, y) { + // Bidi parts are sorted left-to-right, and in a non-line-wrapping + // situation, we can take this ordering to correspond to the visual + // ordering. This finds the first part whose end is after the given + // coordinates. + var index = findFirst(function (i) { + var part = order[i], ltr = part.level != 1; + return boxIsAfter(cursorCoords(cm, Pos(lineNo, ltr ? part.to : part.from, ltr ? "before" : "after"), + "line", lineObj, preparedMeasure), x, y, true) + }, 0, order.length - 1); + var part = order[index]; + // If this isn't the first part, the part's start is also after + // the coordinates, and the coordinates aren't on the same line as + // that start, move one part back. + if (index > 0) { + var ltr = part.level != 1; + var start = cursorCoords(cm, Pos(lineNo, ltr ? part.from : part.to, ltr ? "after" : "before"), + "line", lineObj, preparedMeasure); + if (boxIsAfter(start, x, y, true) && start.top > y) + { part = order[index - 1]; } + } + return part + } + + function coordsBidiPartWrapped(cm, lineObj, _lineNo, preparedMeasure, order, x, y) { + // In a wrapped line, rtl text on wrapping boundaries can do things + // that don't correspond to the ordering in our `order` array at + // all, so a binary search doesn't work, and we want to return a + // part that only spans one line so that the binary search in + // coordsCharInner is safe. As such, we first find the extent of the + // wrapped line, and then do a flat search in which we discard any + // spans that aren't on the line. + var ref = wrappedLineExtent(cm, lineObj, preparedMeasure, y); + var begin = ref.begin; + var end = ref.end; + if (/\s/.test(lineObj.text.charAt(end - 1))) { end--; } + var part = null, closestDist = null; + for (var i = 0; i < order.length; i++) { + var p = order[i]; + if (p.from >= end || p.to <= begin) { continue } + var ltr = p.level != 1; + var endX = measureCharPrepared(cm, preparedMeasure, ltr ? Math.min(end, p.to) - 1 : Math.max(begin, p.from)).right; + // Weigh against spans ending before this, so that they are only + // picked if nothing ends after + var dist = endX < x ? x - endX + 1e9 : endX - x; + if (!part || closestDist > dist) { + part = p; + closestDist = dist; + } + } + if (!part) { part = order[order.length - 1]; } + // Clip the part to the wrapped line. + if (part.from < begin) { part = {from: begin, to: part.to, level: part.level}; } + if (part.to > end) { part = {from: part.from, to: end, level: part.level}; } + return part + } + + var measureText; + // Compute the default text height. + function textHeight(display) { + if (display.cachedTextHeight != null) { return display.cachedTextHeight } + if (measureText == null) { + measureText = elt("pre", null, "CodeMirror-line-like"); + // Measure a bunch of lines, for browsers that compute + // fractional heights. + for (var i = 0; i < 49; ++i) { + measureText.appendChild(document.createTextNode("x")); + measureText.appendChild(elt("br")); + } + measureText.appendChild(document.createTextNode("x")); + } + removeChildrenAndAdd(display.measure, measureText); + var height = measureText.offsetHeight / 50; + if (height > 3) { display.cachedTextHeight = height; } + removeChildren(display.measure); + return height || 1 + } + + // Compute the default character width. + function charWidth(display) { + if (display.cachedCharWidth != null) { return display.cachedCharWidth } + var anchor = elt("span", "xxxxxxxxxx"); + var pre = elt("pre", [anchor], "CodeMirror-line-like"); + removeChildrenAndAdd(display.measure, pre); + var rect = anchor.getBoundingClientRect(), width = (rect.right - rect.left) / 10; + if (width > 2) { display.cachedCharWidth = width; } + return width || 10 + } + + // Do a bulk-read of the DOM positions and sizes needed to draw the + // view, so that we don't interleave reading and writing to the DOM. + function getDimensions(cm) { + var d = cm.display, left = {}, width = {}; + var gutterLeft = d.gutters.clientLeft; + for (var n = d.gutters.firstChild, i = 0; n; n = n.nextSibling, ++i) { + var id = cm.display.gutterSpecs[i].className; + left[id] = n.offsetLeft + n.clientLeft + gutterLeft; + width[id] = n.clientWidth; + } + return {fixedPos: compensateForHScroll(d), + gutterTotalWidth: d.gutters.offsetWidth, + gutterLeft: left, + gutterWidth: width, + wrapperWidth: d.wrapper.clientWidth} + } + + // Computes display.scroller.scrollLeft + display.gutters.offsetWidth, + // but using getBoundingClientRect to get a sub-pixel-accurate + // result. + function compensateForHScroll(display) { + return display.scroller.getBoundingClientRect().left - display.sizer.getBoundingClientRect().left + } + + // Returns a function that estimates the height of a line, to use as + // first approximation until the line becomes visible (and is thus + // properly measurable). + function estimateHeight(cm) { + var th = textHeight(cm.display), wrapping = cm.options.lineWrapping; + var perLine = wrapping && Math.max(5, cm.display.scroller.clientWidth / charWidth(cm.display) - 3); + return function (line) { + if (lineIsHidden(cm.doc, line)) { return 0 } + + var widgetsHeight = 0; + if (line.widgets) { for (var i = 0; i < line.widgets.length; i++) { + if (line.widgets[i].height) { widgetsHeight += line.widgets[i].height; } + } } + + if (wrapping) + { return widgetsHeight + (Math.ceil(line.text.length / perLine) || 1) * th } + else + { return widgetsHeight + th } + } + } + + function estimateLineHeights(cm) { + var doc = cm.doc, est = estimateHeight(cm); + doc.iter(function (line) { + var estHeight = est(line); + if (estHeight != line.height) { updateLineHeight(line, estHeight); } + }); + } + + // Given a mouse event, find the corresponding position. If liberal + // is false, it checks whether a gutter or scrollbar was clicked, + // and returns null if it was. forRect is used by rectangular + // selections, and tries to estimate a character position even for + // coordinates beyond the right of the text. + function posFromMouse(cm, e, liberal, forRect) { + var display = cm.display; + if (!liberal && e_target(e).getAttribute("cm-not-content") == "true") { return null } + + var x, y, space = display.lineSpace.getBoundingClientRect(); + // Fails unpredictably on IE[67] when mouse is dragged around quickly. + try { x = e.clientX - space.left; y = e.clientY - space.top; } + catch (e$1) { return null } + var coords = coordsChar(cm, x, y), line; + if (forRect && coords.xRel > 0 && (line = getLine(cm.doc, coords.line).text).length == coords.ch) { + var colDiff = countColumn(line, line.length, cm.options.tabSize) - line.length; + coords = Pos(coords.line, Math.max(0, Math.round((x - paddingH(cm.display).left) / charWidth(cm.display)) - colDiff)); + } + return coords + } + + // Find the view element corresponding to a given line. Return null + // when the line isn't visible. + function findViewIndex(cm, n) { + if (n >= cm.display.viewTo) { return null } + n -= cm.display.viewFrom; + if (n < 0) { return null } + var view = cm.display.view; + for (var i = 0; i < view.length; i++) { + n -= view[i].size; + if (n < 0) { return i } + } + } + + // Updates the display.view data structure for a given change to the + // document. From and to are in pre-change coordinates. Lendiff is + // the amount of lines added or subtracted by the change. This is + // used for changes that span multiple lines, or change the way + // lines are divided into visual lines. regLineChange (below) + // registers single-line changes. + function regChange(cm, from, to, lendiff) { + if (from == null) { from = cm.doc.first; } + if (to == null) { to = cm.doc.first + cm.doc.size; } + if (!lendiff) { lendiff = 0; } + + var display = cm.display; + if (lendiff && to < display.viewTo && + (display.updateLineNumbers == null || display.updateLineNumbers > from)) + { display.updateLineNumbers = from; } + + cm.curOp.viewChanged = true; + + if (from >= display.viewTo) { // Change after + if (sawCollapsedSpans && visualLineNo(cm.doc, from) < display.viewTo) + { resetView(cm); } + } else if (to <= display.viewFrom) { // Change before + if (sawCollapsedSpans && visualLineEndNo(cm.doc, to + lendiff) > display.viewFrom) { + resetView(cm); + } else { + display.viewFrom += lendiff; + display.viewTo += lendiff; + } + } else if (from <= display.viewFrom && to >= display.viewTo) { // Full overlap + resetView(cm); + } else if (from <= display.viewFrom) { // Top overlap + var cut = viewCuttingPoint(cm, to, to + lendiff, 1); + if (cut) { + display.view = display.view.slice(cut.index); + display.viewFrom = cut.lineN; + display.viewTo += lendiff; + } else { + resetView(cm); + } + } else if (to >= display.viewTo) { // Bottom overlap + var cut$1 = viewCuttingPoint(cm, from, from, -1); + if (cut$1) { + display.view = display.view.slice(0, cut$1.index); + display.viewTo = cut$1.lineN; + } else { + resetView(cm); + } + } else { // Gap in the middle + var cutTop = viewCuttingPoint(cm, from, from, -1); + var cutBot = viewCuttingPoint(cm, to, to + lendiff, 1); + if (cutTop && cutBot) { + display.view = display.view.slice(0, cutTop.index) + .concat(buildViewArray(cm, cutTop.lineN, cutBot.lineN)) + .concat(display.view.slice(cutBot.index)); + display.viewTo += lendiff; + } else { + resetView(cm); + } + } + + var ext = display.externalMeasured; + if (ext) { + if (to < ext.lineN) + { ext.lineN += lendiff; } + else if (from < ext.lineN + ext.size) + { display.externalMeasured = null; } + } + } + + // Register a change to a single line. Type must be one of "text", + // "gutter", "class", "widget" + function regLineChange(cm, line, type) { + cm.curOp.viewChanged = true; + var display = cm.display, ext = cm.display.externalMeasured; + if (ext && line >= ext.lineN && line < ext.lineN + ext.size) + { display.externalMeasured = null; } + + if (line < display.viewFrom || line >= display.viewTo) { return } + var lineView = display.view[findViewIndex(cm, line)]; + if (lineView.node == null) { return } + var arr = lineView.changes || (lineView.changes = []); + if (indexOf(arr, type) == -1) { arr.push(type); } + } + + // Clear the view. + function resetView(cm) { + cm.display.viewFrom = cm.display.viewTo = cm.doc.first; + cm.display.view = []; + cm.display.viewOffset = 0; + } + + function viewCuttingPoint(cm, oldN, newN, dir) { + var index = findViewIndex(cm, oldN), diff, view = cm.display.view; + if (!sawCollapsedSpans || newN == cm.doc.first + cm.doc.size) + { return {index: index, lineN: newN} } + var n = cm.display.viewFrom; + for (var i = 0; i < index; i++) + { n += view[i].size; } + if (n != oldN) { + if (dir > 0) { + if (index == view.length - 1) { return null } + diff = (n + view[index].size) - oldN; + index++; + } else { + diff = n - oldN; + } + oldN += diff; newN += diff; + } + while (visualLineNo(cm.doc, newN) != newN) { + if (index == (dir < 0 ? 0 : view.length - 1)) { return null } + newN += dir * view[index - (dir < 0 ? 1 : 0)].size; + index += dir; + } + return {index: index, lineN: newN} + } + + // Force the view to cover a given range, adding empty view element + // or clipping off existing ones as needed. + function adjustView(cm, from, to) { + var display = cm.display, view = display.view; + if (view.length == 0 || from >= display.viewTo || to <= display.viewFrom) { + display.view = buildViewArray(cm, from, to); + display.viewFrom = from; + } else { + if (display.viewFrom > from) + { display.view = buildViewArray(cm, from, display.viewFrom).concat(display.view); } + else if (display.viewFrom < from) + { display.view = display.view.slice(findViewIndex(cm, from)); } + display.viewFrom = from; + if (display.viewTo < to) + { display.view = display.view.concat(buildViewArray(cm, display.viewTo, to)); } + else if (display.viewTo > to) + { display.view = display.view.slice(0, findViewIndex(cm, to)); } + } + display.viewTo = to; + } + + // Count the number of lines in the view whose DOM representation is + // out of date (or nonexistent). + function countDirtyView(cm) { + var view = cm.display.view, dirty = 0; + for (var i = 0; i < view.length; i++) { + var lineView = view[i]; + if (!lineView.hidden && (!lineView.node || lineView.changes)) { ++dirty; } + } + return dirty + } + + function updateSelection(cm) { + cm.display.input.showSelection(cm.display.input.prepareSelection()); + } + + function prepareSelection(cm, primary) { + if ( primary === void 0 ) primary = true; + + var doc = cm.doc, result = {}; + var curFragment = result.cursors = document.createDocumentFragment(); + var selFragment = result.selection = document.createDocumentFragment(); + + var customCursor = cm.options.$customCursor; + if (customCursor) { primary = true; } + for (var i = 0; i < doc.sel.ranges.length; i++) { + if (!primary && i == doc.sel.primIndex) { continue } + var range = doc.sel.ranges[i]; + if (range.from().line >= cm.display.viewTo || range.to().line < cm.display.viewFrom) { continue } + var collapsed = range.empty(); + if (customCursor) { + var head = customCursor(cm, range); + if (head) { drawSelectionCursor(cm, head, curFragment); } + } else if (collapsed || cm.options.showCursorWhenSelecting) { + drawSelectionCursor(cm, range.head, curFragment); + } + if (!collapsed) + { drawSelectionRange(cm, range, selFragment); } + } + return result + } + + // Draws a cursor for the given range + function drawSelectionCursor(cm, head, output) { + var pos = cursorCoords(cm, head, "div", null, null, !cm.options.singleCursorHeightPerLine); + + var cursor = output.appendChild(elt("div", "\u00a0", "CodeMirror-cursor")); + cursor.style.left = pos.left + "px"; + cursor.style.top = pos.top + "px"; + cursor.style.height = Math.max(0, pos.bottom - pos.top) * cm.options.cursorHeight + "px"; + + if (/\bcm-fat-cursor\b/.test(cm.getWrapperElement().className)) { + var charPos = charCoords(cm, head, "div", null, null); + var width = charPos.right - charPos.left; + cursor.style.width = (width > 0 ? width : cm.defaultCharWidth()) + "px"; + } + + if (pos.other) { + // Secondary cursor, shown when on a 'jump' in bi-directional text + var otherCursor = output.appendChild(elt("div", "\u00a0", "CodeMirror-cursor CodeMirror-secondarycursor")); + otherCursor.style.display = ""; + otherCursor.style.left = pos.other.left + "px"; + otherCursor.style.top = pos.other.top + "px"; + otherCursor.style.height = (pos.other.bottom - pos.other.top) * .85 + "px"; + } + } + + function cmpCoords(a, b) { return a.top - b.top || a.left - b.left } + + // Draws the given range as a highlighted selection + function drawSelectionRange(cm, range, output) { + var display = cm.display, doc = cm.doc; + var fragment = document.createDocumentFragment(); + var padding = paddingH(cm.display), leftSide = padding.left; + var rightSide = Math.max(display.sizerWidth, displayWidth(cm) - display.sizer.offsetLeft) - padding.right; + var docLTR = doc.direction == "ltr"; + + function add(left, top, width, bottom) { + if (top < 0) { top = 0; } + top = Math.round(top); + bottom = Math.round(bottom); + fragment.appendChild(elt("div", null, "CodeMirror-selected", ("position: absolute; left: " + left + "px;\n top: " + top + "px; width: " + (width == null ? rightSide - left : width) + "px;\n height: " + (bottom - top) + "px"))); + } + + function drawForLine(line, fromArg, toArg) { + var lineObj = getLine(doc, line); + var lineLen = lineObj.text.length; + var start, end; + function coords(ch, bias) { + return charCoords(cm, Pos(line, ch), "div", lineObj, bias) + } + + function wrapX(pos, dir, side) { + var extent = wrappedLineExtentChar(cm, lineObj, null, pos); + var prop = (dir == "ltr") == (side == "after") ? "left" : "right"; + var ch = side == "after" ? extent.begin : extent.end - (/\s/.test(lineObj.text.charAt(extent.end - 1)) ? 2 : 1); + return coords(ch, prop)[prop] + } + + var order = getOrder(lineObj, doc.direction); + iterateBidiSections(order, fromArg || 0, toArg == null ? lineLen : toArg, function (from, to, dir, i) { + var ltr = dir == "ltr"; + var fromPos = coords(from, ltr ? "left" : "right"); + var toPos = coords(to - 1, ltr ? "right" : "left"); + + var openStart = fromArg == null && from == 0, openEnd = toArg == null && to == lineLen; + var first = i == 0, last = !order || i == order.length - 1; + if (toPos.top - fromPos.top <= 3) { // Single line + var openLeft = (docLTR ? openStart : openEnd) && first; + var openRight = (docLTR ? openEnd : openStart) && last; + var left = openLeft ? leftSide : (ltr ? fromPos : toPos).left; + var right = openRight ? rightSide : (ltr ? toPos : fromPos).right; + add(left, fromPos.top, right - left, fromPos.bottom); + } else { // Multiple lines + var topLeft, topRight, botLeft, botRight; + if (ltr) { + topLeft = docLTR && openStart && first ? leftSide : fromPos.left; + topRight = docLTR ? rightSide : wrapX(from, dir, "before"); + botLeft = docLTR ? leftSide : wrapX(to, dir, "after"); + botRight = docLTR && openEnd && last ? rightSide : toPos.right; + } else { + topLeft = !docLTR ? leftSide : wrapX(from, dir, "before"); + topRight = !docLTR && openStart && first ? rightSide : fromPos.right; + botLeft = !docLTR && openEnd && last ? leftSide : toPos.left; + botRight = !docLTR ? rightSide : wrapX(to, dir, "after"); + } + add(topLeft, fromPos.top, topRight - topLeft, fromPos.bottom); + if (fromPos.bottom < toPos.top) { add(leftSide, fromPos.bottom, null, toPos.top); } + add(botLeft, toPos.top, botRight - botLeft, toPos.bottom); + } + + if (!start || cmpCoords(fromPos, start) < 0) { start = fromPos; } + if (cmpCoords(toPos, start) < 0) { start = toPos; } + if (!end || cmpCoords(fromPos, end) < 0) { end = fromPos; } + if (cmpCoords(toPos, end) < 0) { end = toPos; } + }); + return {start: start, end: end} + } + + var sFrom = range.from(), sTo = range.to(); + if (sFrom.line == sTo.line) { + drawForLine(sFrom.line, sFrom.ch, sTo.ch); + } else { + var fromLine = getLine(doc, sFrom.line), toLine = getLine(doc, sTo.line); + var singleVLine = visualLine(fromLine) == visualLine(toLine); + var leftEnd = drawForLine(sFrom.line, sFrom.ch, singleVLine ? fromLine.text.length + 1 : null).end; + var rightStart = drawForLine(sTo.line, singleVLine ? 0 : null, sTo.ch).start; + if (singleVLine) { + if (leftEnd.top < rightStart.top - 2) { + add(leftEnd.right, leftEnd.top, null, leftEnd.bottom); + add(leftSide, rightStart.top, rightStart.left, rightStart.bottom); + } else { + add(leftEnd.right, leftEnd.top, rightStart.left - leftEnd.right, leftEnd.bottom); + } + } + if (leftEnd.bottom < rightStart.top) + { add(leftSide, leftEnd.bottom, null, rightStart.top); } + } + + output.appendChild(fragment); + } + + // Cursor-blinking + function restartBlink(cm) { + if (!cm.state.focused) { return } + var display = cm.display; + clearInterval(display.blinker); + var on = true; + display.cursorDiv.style.visibility = ""; + if (cm.options.cursorBlinkRate > 0) + { display.blinker = setInterval(function () { + if (!cm.hasFocus()) { onBlur(cm); } + display.cursorDiv.style.visibility = (on = !on) ? "" : "hidden"; + }, cm.options.cursorBlinkRate); } + else if (cm.options.cursorBlinkRate < 0) + { display.cursorDiv.style.visibility = "hidden"; } + } + + function ensureFocus(cm) { + if (!cm.hasFocus()) { + cm.display.input.focus(); + if (!cm.state.focused) { onFocus(cm); } + } + } + + function delayBlurEvent(cm) { + cm.state.delayingBlurEvent = true; + setTimeout(function () { if (cm.state.delayingBlurEvent) { + cm.state.delayingBlurEvent = false; + if (cm.state.focused) { onBlur(cm); } + } }, 100); + } + + function onFocus(cm, e) { + if (cm.state.delayingBlurEvent && !cm.state.draggingText) { cm.state.delayingBlurEvent = false; } + + if (cm.options.readOnly == "nocursor") { return } + if (!cm.state.focused) { + signal(cm, "focus", cm, e); + cm.state.focused = true; + addClass(cm.display.wrapper, "CodeMirror-focused"); + // This test prevents this from firing when a context + // menu is closed (since the input reset would kill the + // select-all detection hack) + if (!cm.curOp && cm.display.selForContextMenu != cm.doc.sel) { + cm.display.input.reset(); + if (webkit) { setTimeout(function () { return cm.display.input.reset(true); }, 20); } // Issue #1730 + } + cm.display.input.receivedFocus(); + } + restartBlink(cm); + } + function onBlur(cm, e) { + if (cm.state.delayingBlurEvent) { return } + + if (cm.state.focused) { + signal(cm, "blur", cm, e); + cm.state.focused = false; + rmClass(cm.display.wrapper, "CodeMirror-focused"); + } + clearInterval(cm.display.blinker); + setTimeout(function () { if (!cm.state.focused) { cm.display.shift = false; } }, 150); + } + + // Read the actual heights of the rendered lines, and update their + // stored heights to match. + function updateHeightsInViewport(cm) { + var display = cm.display; + var prevBottom = display.lineDiv.offsetTop; + var viewTop = Math.max(0, display.scroller.getBoundingClientRect().top); + var oldHeight = display.lineDiv.getBoundingClientRect().top; + var mustScroll = 0; + for (var i = 0; i < display.view.length; i++) { + var cur = display.view[i], wrapping = cm.options.lineWrapping; + var height = (void 0), width = 0; + if (cur.hidden) { continue } + oldHeight += cur.line.height; + if (ie && ie_version < 8) { + var bot = cur.node.offsetTop + cur.node.offsetHeight; + height = bot - prevBottom; + prevBottom = bot; + } else { + var box = cur.node.getBoundingClientRect(); + height = box.bottom - box.top; + // Check that lines don't extend past the right of the current + // editor width + if (!wrapping && cur.text.firstChild) + { width = cur.text.firstChild.getBoundingClientRect().right - box.left - 1; } + } + var diff = cur.line.height - height; + if (diff > .005 || diff < -.005) { + if (oldHeight < viewTop) { mustScroll -= diff; } + updateLineHeight(cur.line, height); + updateWidgetHeight(cur.line); + if (cur.rest) { for (var j = 0; j < cur.rest.length; j++) + { updateWidgetHeight(cur.rest[j]); } } + } + if (width > cm.display.sizerWidth) { + var chWidth = Math.ceil(width / charWidth(cm.display)); + if (chWidth > cm.display.maxLineLength) { + cm.display.maxLineLength = chWidth; + cm.display.maxLine = cur.line; + cm.display.maxLineChanged = true; + } + } + } + if (Math.abs(mustScroll) > 2) { display.scroller.scrollTop += mustScroll; } + } + + // Read and store the height of line widgets associated with the + // given line. + function updateWidgetHeight(line) { + if (line.widgets) { for (var i = 0; i < line.widgets.length; ++i) { + var w = line.widgets[i], parent = w.node.parentNode; + if (parent) { w.height = parent.offsetHeight; } + } } + } + + // Compute the lines that are visible in a given viewport (defaults + // the current scroll position). viewport may contain top, + // height, and ensure (see op.scrollToPos) properties. + function visibleLines(display, doc, viewport) { + var top = viewport && viewport.top != null ? Math.max(0, viewport.top) : display.scroller.scrollTop; + top = Math.floor(top - paddingTop(display)); + var bottom = viewport && viewport.bottom != null ? viewport.bottom : top + display.wrapper.clientHeight; + + var from = lineAtHeight(doc, top), to = lineAtHeight(doc, bottom); + // Ensure is a {from: {line, ch}, to: {line, ch}} object, and + // forces those lines into the viewport (if possible). + if (viewport && viewport.ensure) { + var ensureFrom = viewport.ensure.from.line, ensureTo = viewport.ensure.to.line; + if (ensureFrom < from) { + from = ensureFrom; + to = lineAtHeight(doc, heightAtLine(getLine(doc, ensureFrom)) + display.wrapper.clientHeight); + } else if (Math.min(ensureTo, doc.lastLine()) >= to) { + from = lineAtHeight(doc, heightAtLine(getLine(doc, ensureTo)) - display.wrapper.clientHeight); + to = ensureTo; + } + } + return {from: from, to: Math.max(to, from + 1)} + } + + // SCROLLING THINGS INTO VIEW + + // If an editor sits on the top or bottom of the window, partially + // scrolled out of view, this ensures that the cursor is visible. + function maybeScrollWindow(cm, rect) { + if (signalDOMEvent(cm, "scrollCursorIntoView")) { return } + + var display = cm.display, box = display.sizer.getBoundingClientRect(), doScroll = null; + var doc = display.wrapper.ownerDocument; + if (rect.top + box.top < 0) { doScroll = true; } + else if (rect.bottom + box.top > (doc.defaultView.innerHeight || doc.documentElement.clientHeight)) { doScroll = false; } + if (doScroll != null && !phantom) { + var scrollNode = elt("div", "\u200b", null, ("position: absolute;\n top: " + (rect.top - display.viewOffset - paddingTop(cm.display)) + "px;\n height: " + (rect.bottom - rect.top + scrollGap(cm) + display.barHeight) + "px;\n left: " + (rect.left) + "px; width: " + (Math.max(2, rect.right - rect.left)) + "px;")); + cm.display.lineSpace.appendChild(scrollNode); + scrollNode.scrollIntoView(doScroll); + cm.display.lineSpace.removeChild(scrollNode); + } + } + + // Scroll a given position into view (immediately), verifying that + // it actually became visible (as line heights are accurately + // measured, the position of something may 'drift' during drawing). + function scrollPosIntoView(cm, pos, end, margin) { + if (margin == null) { margin = 0; } + var rect; + if (!cm.options.lineWrapping && pos == end) { + // Set pos and end to the cursor positions around the character pos sticks to + // If pos.sticky == "before", that is around pos.ch - 1, otherwise around pos.ch + // If pos == Pos(_, 0, "before"), pos and end are unchanged + end = pos.sticky == "before" ? Pos(pos.line, pos.ch + 1, "before") : pos; + pos = pos.ch ? Pos(pos.line, pos.sticky == "before" ? pos.ch - 1 : pos.ch, "after") : pos; + } + for (var limit = 0; limit < 5; limit++) { + var changed = false; + var coords = cursorCoords(cm, pos); + var endCoords = !end || end == pos ? coords : cursorCoords(cm, end); + rect = {left: Math.min(coords.left, endCoords.left), + top: Math.min(coords.top, endCoords.top) - margin, + right: Math.max(coords.left, endCoords.left), + bottom: Math.max(coords.bottom, endCoords.bottom) + margin}; + var scrollPos = calculateScrollPos(cm, rect); + var startTop = cm.doc.scrollTop, startLeft = cm.doc.scrollLeft; + if (scrollPos.scrollTop != null) { + updateScrollTop(cm, scrollPos.scrollTop); + if (Math.abs(cm.doc.scrollTop - startTop) > 1) { changed = true; } + } + if (scrollPos.scrollLeft != null) { + setScrollLeft(cm, scrollPos.scrollLeft); + if (Math.abs(cm.doc.scrollLeft - startLeft) > 1) { changed = true; } + } + if (!changed) { break } + } + return rect + } + + // Scroll a given set of coordinates into view (immediately). + function scrollIntoView(cm, rect) { + var scrollPos = calculateScrollPos(cm, rect); + if (scrollPos.scrollTop != null) { updateScrollTop(cm, scrollPos.scrollTop); } + if (scrollPos.scrollLeft != null) { setScrollLeft(cm, scrollPos.scrollLeft); } + } + + // Calculate a new scroll position needed to scroll the given + // rectangle into view. Returns an object with scrollTop and + // scrollLeft properties. When these are undefined, the + // vertical/horizontal position does not need to be adjusted. + function calculateScrollPos(cm, rect) { + var display = cm.display, snapMargin = textHeight(cm.display); + if (rect.top < 0) { rect.top = 0; } + var screentop = cm.curOp && cm.curOp.scrollTop != null ? cm.curOp.scrollTop : display.scroller.scrollTop; + var screen = displayHeight(cm), result = {}; + if (rect.bottom - rect.top > screen) { rect.bottom = rect.top + screen; } + var docBottom = cm.doc.height + paddingVert(display); + var atTop = rect.top < snapMargin, atBottom = rect.bottom > docBottom - snapMargin; + if (rect.top < screentop) { + result.scrollTop = atTop ? 0 : rect.top; + } else if (rect.bottom > screentop + screen) { + var newTop = Math.min(rect.top, (atBottom ? docBottom : rect.bottom) - screen); + if (newTop != screentop) { result.scrollTop = newTop; } + } + + var gutterSpace = cm.options.fixedGutter ? 0 : display.gutters.offsetWidth; + var screenleft = cm.curOp && cm.curOp.scrollLeft != null ? cm.curOp.scrollLeft : display.scroller.scrollLeft - gutterSpace; + var screenw = displayWidth(cm) - display.gutters.offsetWidth; + var tooWide = rect.right - rect.left > screenw; + if (tooWide) { rect.right = rect.left + screenw; } + if (rect.left < 10) + { result.scrollLeft = 0; } + else if (rect.left < screenleft) + { result.scrollLeft = Math.max(0, rect.left + gutterSpace - (tooWide ? 0 : 10)); } + else if (rect.right > screenw + screenleft - 3) + { result.scrollLeft = rect.right + (tooWide ? 0 : 10) - screenw; } + return result + } + + // Store a relative adjustment to the scroll position in the current + // operation (to be applied when the operation finishes). + function addToScrollTop(cm, top) { + if (top == null) { return } + resolveScrollToPos(cm); + cm.curOp.scrollTop = (cm.curOp.scrollTop == null ? cm.doc.scrollTop : cm.curOp.scrollTop) + top; + } + + // Make sure that at the end of the operation the current cursor is + // shown. + function ensureCursorVisible(cm) { + resolveScrollToPos(cm); + var cur = cm.getCursor(); + cm.curOp.scrollToPos = {from: cur, to: cur, margin: cm.options.cursorScrollMargin}; + } + + function scrollToCoords(cm, x, y) { + if (x != null || y != null) { resolveScrollToPos(cm); } + if (x != null) { cm.curOp.scrollLeft = x; } + if (y != null) { cm.curOp.scrollTop = y; } + } + + function scrollToRange(cm, range) { + resolveScrollToPos(cm); + cm.curOp.scrollToPos = range; + } + + // When an operation has its scrollToPos property set, and another + // scroll action is applied before the end of the operation, this + // 'simulates' scrolling that position into view in a cheap way, so + // that the effect of intermediate scroll commands is not ignored. + function resolveScrollToPos(cm) { + var range = cm.curOp.scrollToPos; + if (range) { + cm.curOp.scrollToPos = null; + var from = estimateCoords(cm, range.from), to = estimateCoords(cm, range.to); + scrollToCoordsRange(cm, from, to, range.margin); + } + } + + function scrollToCoordsRange(cm, from, to, margin) { + var sPos = calculateScrollPos(cm, { + left: Math.min(from.left, to.left), + top: Math.min(from.top, to.top) - margin, + right: Math.max(from.right, to.right), + bottom: Math.max(from.bottom, to.bottom) + margin + }); + scrollToCoords(cm, sPos.scrollLeft, sPos.scrollTop); + } + + // Sync the scrollable area and scrollbars, ensure the viewport + // covers the visible area. + function updateScrollTop(cm, val) { + if (Math.abs(cm.doc.scrollTop - val) < 2) { return } + if (!gecko) { updateDisplaySimple(cm, {top: val}); } + setScrollTop(cm, val, true); + if (gecko) { updateDisplaySimple(cm); } + startWorker(cm, 100); + } + + function setScrollTop(cm, val, forceScroll) { + val = Math.max(0, Math.min(cm.display.scroller.scrollHeight - cm.display.scroller.clientHeight, val)); + if (cm.display.scroller.scrollTop == val && !forceScroll) { return } + cm.doc.scrollTop = val; + cm.display.scrollbars.setScrollTop(val); + if (cm.display.scroller.scrollTop != val) { cm.display.scroller.scrollTop = val; } + } + + // Sync scroller and scrollbar, ensure the gutter elements are + // aligned. + function setScrollLeft(cm, val, isScroller, forceScroll) { + val = Math.max(0, Math.min(val, cm.display.scroller.scrollWidth - cm.display.scroller.clientWidth)); + if ((isScroller ? val == cm.doc.scrollLeft : Math.abs(cm.doc.scrollLeft - val) < 2) && !forceScroll) { return } + cm.doc.scrollLeft = val; + alignHorizontally(cm); + if (cm.display.scroller.scrollLeft != val) { cm.display.scroller.scrollLeft = val; } + cm.display.scrollbars.setScrollLeft(val); + } + + // SCROLLBARS + + // Prepare DOM reads needed to update the scrollbars. Done in one + // shot to minimize update/measure roundtrips. + function measureForScrollbars(cm) { + var d = cm.display, gutterW = d.gutters.offsetWidth; + var docH = Math.round(cm.doc.height + paddingVert(cm.display)); + return { + clientHeight: d.scroller.clientHeight, + viewHeight: d.wrapper.clientHeight, + scrollWidth: d.scroller.scrollWidth, clientWidth: d.scroller.clientWidth, + viewWidth: d.wrapper.clientWidth, + barLeft: cm.options.fixedGutter ? gutterW : 0, + docHeight: docH, + scrollHeight: docH + scrollGap(cm) + d.barHeight, + nativeBarWidth: d.nativeBarWidth, + gutterWidth: gutterW + } + } + + var NativeScrollbars = function(place, scroll, cm) { + this.cm = cm; + var vert = this.vert = elt("div", [elt("div", null, null, "min-width: 1px")], "CodeMirror-vscrollbar"); + var horiz = this.horiz = elt("div", [elt("div", null, null, "height: 100%; min-height: 1px")], "CodeMirror-hscrollbar"); + vert.tabIndex = horiz.tabIndex = -1; + place(vert); place(horiz); + + on(vert, "scroll", function () { + if (vert.clientHeight) { scroll(vert.scrollTop, "vertical"); } + }); + on(horiz, "scroll", function () { + if (horiz.clientWidth) { scroll(horiz.scrollLeft, "horizontal"); } + }); + + this.checkedZeroWidth = false; + // Need to set a minimum width to see the scrollbar on IE7 (but must not set it on IE8). + if (ie && ie_version < 8) { this.horiz.style.minHeight = this.vert.style.minWidth = "18px"; } + }; + + NativeScrollbars.prototype.update = function (measure) { + var needsH = measure.scrollWidth > measure.clientWidth + 1; + var needsV = measure.scrollHeight > measure.clientHeight + 1; + var sWidth = measure.nativeBarWidth; + + if (needsV) { + this.vert.style.display = "block"; + this.vert.style.bottom = needsH ? sWidth + "px" : "0"; + var totalHeight = measure.viewHeight - (needsH ? sWidth : 0); + // A bug in IE8 can cause this value to be negative, so guard it. + this.vert.firstChild.style.height = + Math.max(0, measure.scrollHeight - measure.clientHeight + totalHeight) + "px"; + } else { + this.vert.scrollTop = 0; + this.vert.style.display = ""; + this.vert.firstChild.style.height = "0"; + } + + if (needsH) { + this.horiz.style.display = "block"; + this.horiz.style.right = needsV ? sWidth + "px" : "0"; + this.horiz.style.left = measure.barLeft + "px"; + var totalWidth = measure.viewWidth - measure.barLeft - (needsV ? sWidth : 0); + this.horiz.firstChild.style.width = + Math.max(0, measure.scrollWidth - measure.clientWidth + totalWidth) + "px"; + } else { + this.horiz.style.display = ""; + this.horiz.firstChild.style.width = "0"; + } + + if (!this.checkedZeroWidth && measure.clientHeight > 0) { + if (sWidth == 0) { this.zeroWidthHack(); } + this.checkedZeroWidth = true; + } + + return {right: needsV ? sWidth : 0, bottom: needsH ? sWidth : 0} + }; + + NativeScrollbars.prototype.setScrollLeft = function (pos) { + if (this.horiz.scrollLeft != pos) { this.horiz.scrollLeft = pos; } + if (this.disableHoriz) { this.enableZeroWidthBar(this.horiz, this.disableHoriz, "horiz"); } + }; + + NativeScrollbars.prototype.setScrollTop = function (pos) { + if (this.vert.scrollTop != pos) { this.vert.scrollTop = pos; } + if (this.disableVert) { this.enableZeroWidthBar(this.vert, this.disableVert, "vert"); } + }; + + NativeScrollbars.prototype.zeroWidthHack = function () { + var w = mac && !mac_geMountainLion ? "12px" : "18px"; + this.horiz.style.height = this.vert.style.width = w; + this.horiz.style.visibility = this.vert.style.visibility = "hidden"; + this.disableHoriz = new Delayed; + this.disableVert = new Delayed; + }; + + NativeScrollbars.prototype.enableZeroWidthBar = function (bar, delay, type) { + bar.style.visibility = ""; + function maybeDisable() { + // To find out whether the scrollbar is still visible, we + // check whether the element under the pixel in the bottom + // right corner of the scrollbar box is the scrollbar box + // itself (when the bar is still visible) or its filler child + // (when the bar is hidden). If it is still visible, we keep + // it enabled, if it's hidden, we disable pointer events. + var box = bar.getBoundingClientRect(); + var elt = type == "vert" ? document.elementFromPoint(box.right - 1, (box.top + box.bottom) / 2) + : document.elementFromPoint((box.right + box.left) / 2, box.bottom - 1); + if (elt != bar) { bar.style.visibility = "hidden"; } + else { delay.set(1000, maybeDisable); } + } + delay.set(1000, maybeDisable); + }; + + NativeScrollbars.prototype.clear = function () { + var parent = this.horiz.parentNode; + parent.removeChild(this.horiz); + parent.removeChild(this.vert); + }; + + var NullScrollbars = function () {}; + + NullScrollbars.prototype.update = function () { return {bottom: 0, right: 0} }; + NullScrollbars.prototype.setScrollLeft = function () {}; + NullScrollbars.prototype.setScrollTop = function () {}; + NullScrollbars.prototype.clear = function () {}; + + function updateScrollbars(cm, measure) { + if (!measure) { measure = measureForScrollbars(cm); } + var startWidth = cm.display.barWidth, startHeight = cm.display.barHeight; + updateScrollbarsInner(cm, measure); + for (var i = 0; i < 4 && startWidth != cm.display.barWidth || startHeight != cm.display.barHeight; i++) { + if (startWidth != cm.display.barWidth && cm.options.lineWrapping) + { updateHeightsInViewport(cm); } + updateScrollbarsInner(cm, measureForScrollbars(cm)); + startWidth = cm.display.barWidth; startHeight = cm.display.barHeight; + } + } + + // Re-synchronize the fake scrollbars with the actual size of the + // content. + function updateScrollbarsInner(cm, measure) { + var d = cm.display; + var sizes = d.scrollbars.update(measure); + + d.sizer.style.paddingRight = (d.barWidth = sizes.right) + "px"; + d.sizer.style.paddingBottom = (d.barHeight = sizes.bottom) + "px"; + d.heightForcer.style.borderBottom = sizes.bottom + "px solid transparent"; + + if (sizes.right && sizes.bottom) { + d.scrollbarFiller.style.display = "block"; + d.scrollbarFiller.style.height = sizes.bottom + "px"; + d.scrollbarFiller.style.width = sizes.right + "px"; + } else { d.scrollbarFiller.style.display = ""; } + if (sizes.bottom && cm.options.coverGutterNextToScrollbar && cm.options.fixedGutter) { + d.gutterFiller.style.display = "block"; + d.gutterFiller.style.height = sizes.bottom + "px"; + d.gutterFiller.style.width = measure.gutterWidth + "px"; + } else { d.gutterFiller.style.display = ""; } + } + + var scrollbarModel = {"native": NativeScrollbars, "null": NullScrollbars}; + + function initScrollbars(cm) { + if (cm.display.scrollbars) { + cm.display.scrollbars.clear(); + if (cm.display.scrollbars.addClass) + { rmClass(cm.display.wrapper, cm.display.scrollbars.addClass); } + } + + cm.display.scrollbars = new scrollbarModel[cm.options.scrollbarStyle](function (node) { + cm.display.wrapper.insertBefore(node, cm.display.scrollbarFiller); + // Prevent clicks in the scrollbars from killing focus + on(node, "mousedown", function () { + if (cm.state.focused) { setTimeout(function () { return cm.display.input.focus(); }, 0); } + }); + node.setAttribute("cm-not-content", "true"); + }, function (pos, axis) { + if (axis == "horizontal") { setScrollLeft(cm, pos); } + else { updateScrollTop(cm, pos); } + }, cm); + if (cm.display.scrollbars.addClass) + { addClass(cm.display.wrapper, cm.display.scrollbars.addClass); } + } + + // Operations are used to wrap a series of changes to the editor + // state in such a way that each change won't have to update the + // cursor and display (which would be awkward, slow, and + // error-prone). Instead, display updates are batched and then all + // combined and executed at once. + + var nextOpId = 0; + // Start a new operation. + function startOperation(cm) { + cm.curOp = { + cm: cm, + viewChanged: false, // Flag that indicates that lines might need to be redrawn + startHeight: cm.doc.height, // Used to detect need to update scrollbar + forceUpdate: false, // Used to force a redraw + updateInput: 0, // Whether to reset the input textarea + typing: false, // Whether this reset should be careful to leave existing text (for compositing) + changeObjs: null, // Accumulated changes, for firing change events + cursorActivityHandlers: null, // Set of handlers to fire cursorActivity on + cursorActivityCalled: 0, // Tracks which cursorActivity handlers have been called already + selectionChanged: false, // Whether the selection needs to be redrawn + updateMaxLine: false, // Set when the widest line needs to be determined anew + scrollLeft: null, scrollTop: null, // Intermediate scroll position, not pushed to DOM yet + scrollToPos: null, // Used to scroll to a specific position + focus: false, + id: ++nextOpId, // Unique ID + markArrays: null // Used by addMarkedSpan + }; + pushOperation(cm.curOp); + } + + // Finish an operation, updating the display and signalling delayed events + function endOperation(cm) { + var op = cm.curOp; + if (op) { finishOperation(op, function (group) { + for (var i = 0; i < group.ops.length; i++) + { group.ops[i].cm.curOp = null; } + endOperations(group); + }); } + } + + // The DOM updates done when an operation finishes are batched so + // that the minimum number of relayouts are required. + function endOperations(group) { + var ops = group.ops; + for (var i = 0; i < ops.length; i++) // Read DOM + { endOperation_R1(ops[i]); } + for (var i$1 = 0; i$1 < ops.length; i$1++) // Write DOM (maybe) + { endOperation_W1(ops[i$1]); } + for (var i$2 = 0; i$2 < ops.length; i$2++) // Read DOM + { endOperation_R2(ops[i$2]); } + for (var i$3 = 0; i$3 < ops.length; i$3++) // Write DOM (maybe) + { endOperation_W2(ops[i$3]); } + for (var i$4 = 0; i$4 < ops.length; i$4++) // Read DOM + { endOperation_finish(ops[i$4]); } + } + + function endOperation_R1(op) { + var cm = op.cm, display = cm.display; + maybeClipScrollbars(cm); + if (op.updateMaxLine) { findMaxLine(cm); } + + op.mustUpdate = op.viewChanged || op.forceUpdate || op.scrollTop != null || + op.scrollToPos && (op.scrollToPos.from.line < display.viewFrom || + op.scrollToPos.to.line >= display.viewTo) || + display.maxLineChanged && cm.options.lineWrapping; + op.update = op.mustUpdate && + new DisplayUpdate(cm, op.mustUpdate && {top: op.scrollTop, ensure: op.scrollToPos}, op.forceUpdate); + } + + function endOperation_W1(op) { + op.updatedDisplay = op.mustUpdate && updateDisplayIfNeeded(op.cm, op.update); + } + + function endOperation_R2(op) { + var cm = op.cm, display = cm.display; + if (op.updatedDisplay) { updateHeightsInViewport(cm); } + + op.barMeasure = measureForScrollbars(cm); + + // If the max line changed since it was last measured, measure it, + // and ensure the document's width matches it. + // updateDisplay_W2 will use these properties to do the actual resizing + if (display.maxLineChanged && !cm.options.lineWrapping) { + op.adjustWidthTo = measureChar(cm, display.maxLine, display.maxLine.text.length).left + 3; + cm.display.sizerWidth = op.adjustWidthTo; + op.barMeasure.scrollWidth = + Math.max(display.scroller.clientWidth, display.sizer.offsetLeft + op.adjustWidthTo + scrollGap(cm) + cm.display.barWidth); + op.maxScrollLeft = Math.max(0, display.sizer.offsetLeft + op.adjustWidthTo - displayWidth(cm)); + } + + if (op.updatedDisplay || op.selectionChanged) + { op.preparedSelection = display.input.prepareSelection(); } + } + + function endOperation_W2(op) { + var cm = op.cm; + + if (op.adjustWidthTo != null) { + cm.display.sizer.style.minWidth = op.adjustWidthTo + "px"; + if (op.maxScrollLeft < cm.doc.scrollLeft) + { setScrollLeft(cm, Math.min(cm.display.scroller.scrollLeft, op.maxScrollLeft), true); } + cm.display.maxLineChanged = false; + } + + var takeFocus = op.focus && op.focus == activeElt(root(cm)); + if (op.preparedSelection) + { cm.display.input.showSelection(op.preparedSelection, takeFocus); } + if (op.updatedDisplay || op.startHeight != cm.doc.height) + { updateScrollbars(cm, op.barMeasure); } + if (op.updatedDisplay) + { setDocumentHeight(cm, op.barMeasure); } + + if (op.selectionChanged) { restartBlink(cm); } + + if (cm.state.focused && op.updateInput) + { cm.display.input.reset(op.typing); } + if (takeFocus) { ensureFocus(op.cm); } + } + + function endOperation_finish(op) { + var cm = op.cm, display = cm.display, doc = cm.doc; + + if (op.updatedDisplay) { postUpdateDisplay(cm, op.update); } + + // Abort mouse wheel delta measurement, when scrolling explicitly + if (display.wheelStartX != null && (op.scrollTop != null || op.scrollLeft != null || op.scrollToPos)) + { display.wheelStartX = display.wheelStartY = null; } + + // Propagate the scroll position to the actual DOM scroller + if (op.scrollTop != null) { setScrollTop(cm, op.scrollTop, op.forceScroll); } + + if (op.scrollLeft != null) { setScrollLeft(cm, op.scrollLeft, true, true); } + // If we need to scroll a specific position into view, do so. + if (op.scrollToPos) { + var rect = scrollPosIntoView(cm, clipPos(doc, op.scrollToPos.from), + clipPos(doc, op.scrollToPos.to), op.scrollToPos.margin); + maybeScrollWindow(cm, rect); + } + + // Fire events for markers that are hidden/unidden by editing or + // undoing + var hidden = op.maybeHiddenMarkers, unhidden = op.maybeUnhiddenMarkers; + if (hidden) { for (var i = 0; i < hidden.length; ++i) + { if (!hidden[i].lines.length) { signal(hidden[i], "hide"); } } } + if (unhidden) { for (var i$1 = 0; i$1 < unhidden.length; ++i$1) + { if (unhidden[i$1].lines.length) { signal(unhidden[i$1], "unhide"); } } } + + if (display.wrapper.offsetHeight) + { doc.scrollTop = cm.display.scroller.scrollTop; } + + // Fire change events, and delayed event handlers + if (op.changeObjs) + { signal(cm, "changes", cm, op.changeObjs); } + if (op.update) + { op.update.finish(); } + } + + // Run the given function in an operation + function runInOp(cm, f) { + if (cm.curOp) { return f() } + startOperation(cm); + try { return f() } + finally { endOperation(cm); } + } + // Wraps a function in an operation. Returns the wrapped function. + function operation(cm, f) { + return function() { + if (cm.curOp) { return f.apply(cm, arguments) } + startOperation(cm); + try { return f.apply(cm, arguments) } + finally { endOperation(cm); } + } + } + // Used to add methods to editor and doc instances, wrapping them in + // operations. + function methodOp(f) { + return function() { + if (this.curOp) { return f.apply(this, arguments) } + startOperation(this); + try { return f.apply(this, arguments) } + finally { endOperation(this); } + } + } + function docMethodOp(f) { + return function() { + var cm = this.cm; + if (!cm || cm.curOp) { return f.apply(this, arguments) } + startOperation(cm); + try { return f.apply(this, arguments) } + finally { endOperation(cm); } + } + } + + // HIGHLIGHT WORKER + + function startWorker(cm, time) { + if (cm.doc.highlightFrontier < cm.display.viewTo) + { cm.state.highlight.set(time, bind(highlightWorker, cm)); } + } + + function highlightWorker(cm) { + var doc = cm.doc; + if (doc.highlightFrontier >= cm.display.viewTo) { return } + var end = +new Date + cm.options.workTime; + var context = getContextBefore(cm, doc.highlightFrontier); + var changedLines = []; + + doc.iter(context.line, Math.min(doc.first + doc.size, cm.display.viewTo + 500), function (line) { + if (context.line >= cm.display.viewFrom) { // Visible + var oldStyles = line.styles; + var resetState = line.text.length > cm.options.maxHighlightLength ? copyState(doc.mode, context.state) : null; + var highlighted = highlightLine(cm, line, context, true); + if (resetState) { context.state = resetState; } + line.styles = highlighted.styles; + var oldCls = line.styleClasses, newCls = highlighted.classes; + if (newCls) { line.styleClasses = newCls; } + else if (oldCls) { line.styleClasses = null; } + var ischange = !oldStyles || oldStyles.length != line.styles.length || + oldCls != newCls && (!oldCls || !newCls || oldCls.bgClass != newCls.bgClass || oldCls.textClass != newCls.textClass); + for (var i = 0; !ischange && i < oldStyles.length; ++i) { ischange = oldStyles[i] != line.styles[i]; } + if (ischange) { changedLines.push(context.line); } + line.stateAfter = context.save(); + context.nextLine(); + } else { + if (line.text.length <= cm.options.maxHighlightLength) + { processLine(cm, line.text, context); } + line.stateAfter = context.line % 5 == 0 ? context.save() : null; + context.nextLine(); + } + if (+new Date > end) { + startWorker(cm, cm.options.workDelay); + return true + } + }); + doc.highlightFrontier = context.line; + doc.modeFrontier = Math.max(doc.modeFrontier, context.line); + if (changedLines.length) { runInOp(cm, function () { + for (var i = 0; i < changedLines.length; i++) + { regLineChange(cm, changedLines[i], "text"); } + }); } + } + + // DISPLAY DRAWING + + var DisplayUpdate = function(cm, viewport, force) { + var display = cm.display; + + this.viewport = viewport; + // Store some values that we'll need later (but don't want to force a relayout for) + this.visible = visibleLines(display, cm.doc, viewport); + this.editorIsHidden = !display.wrapper.offsetWidth; + this.wrapperHeight = display.wrapper.clientHeight; + this.wrapperWidth = display.wrapper.clientWidth; + this.oldDisplayWidth = displayWidth(cm); + this.force = force; + this.dims = getDimensions(cm); + this.events = []; + }; + + DisplayUpdate.prototype.signal = function (emitter, type) { + if (hasHandler(emitter, type)) + { this.events.push(arguments); } + }; + DisplayUpdate.prototype.finish = function () { + for (var i = 0; i < this.events.length; i++) + { signal.apply(null, this.events[i]); } + }; + + function maybeClipScrollbars(cm) { + var display = cm.display; + if (!display.scrollbarsClipped && display.scroller.offsetWidth) { + display.nativeBarWidth = display.scroller.offsetWidth - display.scroller.clientWidth; + display.heightForcer.style.height = scrollGap(cm) + "px"; + display.sizer.style.marginBottom = -display.nativeBarWidth + "px"; + display.sizer.style.borderRightWidth = scrollGap(cm) + "px"; + display.scrollbarsClipped = true; + } + } + + function selectionSnapshot(cm) { + if (cm.hasFocus()) { return null } + var active = activeElt(root(cm)); + if (!active || !contains(cm.display.lineDiv, active)) { return null } + var result = {activeElt: active}; + if (window.getSelection) { + var sel = win(cm).getSelection(); + if (sel.anchorNode && sel.extend && contains(cm.display.lineDiv, sel.anchorNode)) { + result.anchorNode = sel.anchorNode; + result.anchorOffset = sel.anchorOffset; + result.focusNode = sel.focusNode; + result.focusOffset = sel.focusOffset; + } + } + return result + } + + function restoreSelection(snapshot) { + if (!snapshot || !snapshot.activeElt || snapshot.activeElt == activeElt(rootNode(snapshot.activeElt))) { return } + snapshot.activeElt.focus(); + if (!/^(INPUT|TEXTAREA)$/.test(snapshot.activeElt.nodeName) && + snapshot.anchorNode && contains(document.body, snapshot.anchorNode) && contains(document.body, snapshot.focusNode)) { + var doc = snapshot.activeElt.ownerDocument; + var sel = doc.defaultView.getSelection(), range = doc.createRange(); + range.setEnd(snapshot.anchorNode, snapshot.anchorOffset); + range.collapse(false); + sel.removeAllRanges(); + sel.addRange(range); + sel.extend(snapshot.focusNode, snapshot.focusOffset); + } + } + + // Does the actual updating of the line display. Bails out + // (returning false) when there is nothing to be done and forced is + // false. + function updateDisplayIfNeeded(cm, update) { + var display = cm.display, doc = cm.doc; + + if (update.editorIsHidden) { + resetView(cm); + return false + } + + // Bail out if the visible area is already rendered and nothing changed. + if (!update.force && + update.visible.from >= display.viewFrom && update.visible.to <= display.viewTo && + (display.updateLineNumbers == null || display.updateLineNumbers >= display.viewTo) && + display.renderedView == display.view && countDirtyView(cm) == 0) + { return false } + + if (maybeUpdateLineNumberWidth(cm)) { + resetView(cm); + update.dims = getDimensions(cm); + } + + // Compute a suitable new viewport (from & to) + var end = doc.first + doc.size; + var from = Math.max(update.visible.from - cm.options.viewportMargin, doc.first); + var to = Math.min(end, update.visible.to + cm.options.viewportMargin); + if (display.viewFrom < from && from - display.viewFrom < 20) { from = Math.max(doc.first, display.viewFrom); } + if (display.viewTo > to && display.viewTo - to < 20) { to = Math.min(end, display.viewTo); } + if (sawCollapsedSpans) { + from = visualLineNo(cm.doc, from); + to = visualLineEndNo(cm.doc, to); + } + + var different = from != display.viewFrom || to != display.viewTo || + display.lastWrapHeight != update.wrapperHeight || display.lastWrapWidth != update.wrapperWidth; + adjustView(cm, from, to); + + display.viewOffset = heightAtLine(getLine(cm.doc, display.viewFrom)); + // Position the mover div to align with the current scroll position + cm.display.mover.style.top = display.viewOffset + "px"; + + var toUpdate = countDirtyView(cm); + if (!different && toUpdate == 0 && !update.force && display.renderedView == display.view && + (display.updateLineNumbers == null || display.updateLineNumbers >= display.viewTo)) + { return false } + + // For big changes, we hide the enclosing element during the + // update, since that speeds up the operations on most browsers. + var selSnapshot = selectionSnapshot(cm); + if (toUpdate > 4) { display.lineDiv.style.display = "none"; } + patchDisplay(cm, display.updateLineNumbers, update.dims); + if (toUpdate > 4) { display.lineDiv.style.display = ""; } + display.renderedView = display.view; + // There might have been a widget with a focused element that got + // hidden or updated, if so re-focus it. + restoreSelection(selSnapshot); + + // Prevent selection and cursors from interfering with the scroll + // width and height. + removeChildren(display.cursorDiv); + removeChildren(display.selectionDiv); + display.gutters.style.height = display.sizer.style.minHeight = 0; + + if (different) { + display.lastWrapHeight = update.wrapperHeight; + display.lastWrapWidth = update.wrapperWidth; + startWorker(cm, 400); + } + + display.updateLineNumbers = null; + + return true + } + + function postUpdateDisplay(cm, update) { + var viewport = update.viewport; + + for (var first = true;; first = false) { + if (!first || !cm.options.lineWrapping || update.oldDisplayWidth == displayWidth(cm)) { + // Clip forced viewport to actual scrollable area. + if (viewport && viewport.top != null) + { viewport = {top: Math.min(cm.doc.height + paddingVert(cm.display) - displayHeight(cm), viewport.top)}; } + // Updated line heights might result in the drawn area not + // actually covering the viewport. Keep looping until it does. + update.visible = visibleLines(cm.display, cm.doc, viewport); + if (update.visible.from >= cm.display.viewFrom && update.visible.to <= cm.display.viewTo) + { break } + } else if (first) { + update.visible = visibleLines(cm.display, cm.doc, viewport); + } + if (!updateDisplayIfNeeded(cm, update)) { break } + updateHeightsInViewport(cm); + var barMeasure = measureForScrollbars(cm); + updateSelection(cm); + updateScrollbars(cm, barMeasure); + setDocumentHeight(cm, barMeasure); + update.force = false; + } + + update.signal(cm, "update", cm); + if (cm.display.viewFrom != cm.display.reportedViewFrom || cm.display.viewTo != cm.display.reportedViewTo) { + update.signal(cm, "viewportChange", cm, cm.display.viewFrom, cm.display.viewTo); + cm.display.reportedViewFrom = cm.display.viewFrom; cm.display.reportedViewTo = cm.display.viewTo; + } + } + + function updateDisplaySimple(cm, viewport) { + var update = new DisplayUpdate(cm, viewport); + if (updateDisplayIfNeeded(cm, update)) { + updateHeightsInViewport(cm); + postUpdateDisplay(cm, update); + var barMeasure = measureForScrollbars(cm); + updateSelection(cm); + updateScrollbars(cm, barMeasure); + setDocumentHeight(cm, barMeasure); + update.finish(); + } + } + + // Sync the actual display DOM structure with display.view, removing + // nodes for lines that are no longer in view, and creating the ones + // that are not there yet, and updating the ones that are out of + // date. + function patchDisplay(cm, updateNumbersFrom, dims) { + var display = cm.display, lineNumbers = cm.options.lineNumbers; + var container = display.lineDiv, cur = container.firstChild; + + function rm(node) { + var next = node.nextSibling; + // Works around a throw-scroll bug in OS X Webkit + if (webkit && mac && cm.display.currentWheelTarget == node) + { node.style.display = "none"; } + else + { node.parentNode.removeChild(node); } + return next + } + + var view = display.view, lineN = display.viewFrom; + // Loop over the elements in the view, syncing cur (the DOM nodes + // in display.lineDiv) with the view as we go. + for (var i = 0; i < view.length; i++) { + var lineView = view[i]; + if (lineView.hidden) ; else if (!lineView.node || lineView.node.parentNode != container) { // Not drawn yet + var node = buildLineElement(cm, lineView, lineN, dims); + container.insertBefore(node, cur); + } else { // Already drawn + while (cur != lineView.node) { cur = rm(cur); } + var updateNumber = lineNumbers && updateNumbersFrom != null && + updateNumbersFrom <= lineN && lineView.lineNumber; + if (lineView.changes) { + if (indexOf(lineView.changes, "gutter") > -1) { updateNumber = false; } + updateLineForChanges(cm, lineView, lineN, dims); + } + if (updateNumber) { + removeChildren(lineView.lineNumber); + lineView.lineNumber.appendChild(document.createTextNode(lineNumberFor(cm.options, lineN))); + } + cur = lineView.node.nextSibling; + } + lineN += lineView.size; + } + while (cur) { cur = rm(cur); } + } + + function updateGutterSpace(display) { + var width = display.gutters.offsetWidth; + display.sizer.style.marginLeft = width + "px"; + // Send an event to consumers responding to changes in gutter width. + signalLater(display, "gutterChanged", display); + } + + function setDocumentHeight(cm, measure) { + cm.display.sizer.style.minHeight = measure.docHeight + "px"; + cm.display.heightForcer.style.top = measure.docHeight + "px"; + cm.display.gutters.style.height = (measure.docHeight + cm.display.barHeight + scrollGap(cm)) + "px"; + } + + // Re-align line numbers and gutter marks to compensate for + // horizontal scrolling. + function alignHorizontally(cm) { + var display = cm.display, view = display.view; + if (!display.alignWidgets && (!display.gutters.firstChild || !cm.options.fixedGutter)) { return } + var comp = compensateForHScroll(display) - display.scroller.scrollLeft + cm.doc.scrollLeft; + var gutterW = display.gutters.offsetWidth, left = comp + "px"; + for (var i = 0; i < view.length; i++) { if (!view[i].hidden) { + if (cm.options.fixedGutter) { + if (view[i].gutter) + { view[i].gutter.style.left = left; } + if (view[i].gutterBackground) + { view[i].gutterBackground.style.left = left; } + } + var align = view[i].alignable; + if (align) { for (var j = 0; j < align.length; j++) + { align[j].style.left = left; } } + } } + if (cm.options.fixedGutter) + { display.gutters.style.left = (comp + gutterW) + "px"; } + } + + // Used to ensure that the line number gutter is still the right + // size for the current document size. Returns true when an update + // is needed. + function maybeUpdateLineNumberWidth(cm) { + if (!cm.options.lineNumbers) { return false } + var doc = cm.doc, last = lineNumberFor(cm.options, doc.first + doc.size - 1), display = cm.display; + if (last.length != display.lineNumChars) { + var test = display.measure.appendChild(elt("div", [elt("div", last)], + "CodeMirror-linenumber CodeMirror-gutter-elt")); + var innerW = test.firstChild.offsetWidth, padding = test.offsetWidth - innerW; + display.lineGutter.style.width = ""; + display.lineNumInnerWidth = Math.max(innerW, display.lineGutter.offsetWidth - padding) + 1; + display.lineNumWidth = display.lineNumInnerWidth + padding; + display.lineNumChars = display.lineNumInnerWidth ? last.length : -1; + display.lineGutter.style.width = display.lineNumWidth + "px"; + updateGutterSpace(cm.display); + return true + } + return false + } + + function getGutters(gutters, lineNumbers) { + var result = [], sawLineNumbers = false; + for (var i = 0; i < gutters.length; i++) { + var name = gutters[i], style = null; + if (typeof name != "string") { style = name.style; name = name.className; } + if (name == "CodeMirror-linenumbers") { + if (!lineNumbers) { continue } + else { sawLineNumbers = true; } + } + result.push({className: name, style: style}); + } + if (lineNumbers && !sawLineNumbers) { result.push({className: "CodeMirror-linenumbers", style: null}); } + return result + } + + // Rebuild the gutter elements, ensure the margin to the left of the + // code matches their width. + function renderGutters(display) { + var gutters = display.gutters, specs = display.gutterSpecs; + removeChildren(gutters); + display.lineGutter = null; + for (var i = 0; i < specs.length; ++i) { + var ref = specs[i]; + var className = ref.className; + var style = ref.style; + var gElt = gutters.appendChild(elt("div", null, "CodeMirror-gutter " + className)); + if (style) { gElt.style.cssText = style; } + if (className == "CodeMirror-linenumbers") { + display.lineGutter = gElt; + gElt.style.width = (display.lineNumWidth || 1) + "px"; + } + } + gutters.style.display = specs.length ? "" : "none"; + updateGutterSpace(display); + } + + function updateGutters(cm) { + renderGutters(cm.display); + regChange(cm); + alignHorizontally(cm); + } + + // The display handles the DOM integration, both for input reading + // and content drawing. It holds references to DOM nodes and + // display-related state. + + function Display(place, doc, input, options) { + var d = this; + this.input = input; + + // Covers bottom-right square when both scrollbars are present. + d.scrollbarFiller = elt("div", null, "CodeMirror-scrollbar-filler"); + d.scrollbarFiller.setAttribute("cm-not-content", "true"); + // Covers bottom of gutter when coverGutterNextToScrollbar is on + // and h scrollbar is present. + d.gutterFiller = elt("div", null, "CodeMirror-gutter-filler"); + d.gutterFiller.setAttribute("cm-not-content", "true"); + // Will contain the actual code, positioned to cover the viewport. + d.lineDiv = eltP("div", null, "CodeMirror-code"); + // Elements are added to these to represent selection and cursors. + d.selectionDiv = elt("div", null, null, "position: relative; z-index: 1"); + d.cursorDiv = elt("div", null, "CodeMirror-cursors"); + // A visibility: hidden element used to find the size of things. + d.measure = elt("div", null, "CodeMirror-measure"); + // When lines outside of the viewport are measured, they are drawn in this. + d.lineMeasure = elt("div", null, "CodeMirror-measure"); + // Wraps everything that needs to exist inside the vertically-padded coordinate system + d.lineSpace = eltP("div", [d.measure, d.lineMeasure, d.selectionDiv, d.cursorDiv, d.lineDiv], + null, "position: relative; outline: none"); + var lines = eltP("div", [d.lineSpace], "CodeMirror-lines"); + // Moved around its parent to cover visible view. + d.mover = elt("div", [lines], null, "position: relative"); + // Set to the height of the document, allowing scrolling. + d.sizer = elt("div", [d.mover], "CodeMirror-sizer"); + d.sizerWidth = null; + // Behavior of elts with overflow: auto and padding is + // inconsistent across browsers. This is used to ensure the + // scrollable area is big enough. + d.heightForcer = elt("div", null, null, "position: absolute; height: " + scrollerGap + "px; width: 1px;"); + // Will contain the gutters, if any. + d.gutters = elt("div", null, "CodeMirror-gutters"); + d.lineGutter = null; + // Actual scrollable element. + d.scroller = elt("div", [d.sizer, d.heightForcer, d.gutters], "CodeMirror-scroll"); + d.scroller.setAttribute("tabIndex", "-1"); + // The element in which the editor lives. + d.wrapper = elt("div", [d.scrollbarFiller, d.gutterFiller, d.scroller], "CodeMirror"); + // See #6982. FIXME remove when this has been fixed for a while in Chrome + if (chrome && chrome_version >= 105) { d.wrapper.style.clipPath = "inset(0px)"; } + + // This attribute is respected by automatic translation systems such as Google Translate, + // and may also be respected by tools used by human translators. + d.wrapper.setAttribute('translate', 'no'); + + // Work around IE7 z-index bug (not perfect, hence IE7 not really being supported) + if (ie && ie_version < 8) { d.gutters.style.zIndex = -1; d.scroller.style.paddingRight = 0; } + if (!webkit && !(gecko && mobile)) { d.scroller.draggable = true; } + + if (place) { + if (place.appendChild) { place.appendChild(d.wrapper); } + else { place(d.wrapper); } + } + + // Current rendered range (may be bigger than the view window). + d.viewFrom = d.viewTo = doc.first; + d.reportedViewFrom = d.reportedViewTo = doc.first; + // Information about the rendered lines. + d.view = []; + d.renderedView = null; + // Holds info about a single rendered line when it was rendered + // for measurement, while not in view. + d.externalMeasured = null; + // Empty space (in pixels) above the view + d.viewOffset = 0; + d.lastWrapHeight = d.lastWrapWidth = 0; + d.updateLineNumbers = null; + + d.nativeBarWidth = d.barHeight = d.barWidth = 0; + d.scrollbarsClipped = false; + + // Used to only resize the line number gutter when necessary (when + // the amount of lines crosses a boundary that makes its width change) + d.lineNumWidth = d.lineNumInnerWidth = d.lineNumChars = null; + // Set to true when a non-horizontal-scrolling line widget is + // added. As an optimization, line widget aligning is skipped when + // this is false. + d.alignWidgets = false; + + d.cachedCharWidth = d.cachedTextHeight = d.cachedPaddingH = null; + + // Tracks the maximum line length so that the horizontal scrollbar + // can be kept static when scrolling. + d.maxLine = null; + d.maxLineLength = 0; + d.maxLineChanged = false; + + // Used for measuring wheel scrolling granularity + d.wheelDX = d.wheelDY = d.wheelStartX = d.wheelStartY = null; + + // True when shift is held down. + d.shift = false; + + // Used to track whether anything happened since the context menu + // was opened. + d.selForContextMenu = null; + + d.activeTouch = null; + + d.gutterSpecs = getGutters(options.gutters, options.lineNumbers); + renderGutters(d); + + input.init(d); + } + + // Since the delta values reported on mouse wheel events are + // unstandardized between browsers and even browser versions, and + // generally horribly unpredictable, this code starts by measuring + // the scroll effect that the first few mouse wheel events have, + // and, from that, detects the way it can convert deltas to pixel + // offsets afterwards. + // + // The reason we want to know the amount a wheel event will scroll + // is that it gives us a chance to update the display before the + // actual scrolling happens, reducing flickering. + + var wheelSamples = 0, wheelPixelsPerUnit = null; + // Fill in a browser-detected starting value on browsers where we + // know one. These don't have to be accurate -- the result of them + // being wrong would just be a slight flicker on the first wheel + // scroll (if it is large enough). + if (ie) { wheelPixelsPerUnit = -.53; } + else if (gecko) { wheelPixelsPerUnit = 15; } + else if (chrome) { wheelPixelsPerUnit = -.7; } + else if (safari) { wheelPixelsPerUnit = -1/3; } + + function wheelEventDelta(e) { + var dx = e.wheelDeltaX, dy = e.wheelDeltaY; + if (dx == null && e.detail && e.axis == e.HORIZONTAL_AXIS) { dx = e.detail; } + if (dy == null && e.detail && e.axis == e.VERTICAL_AXIS) { dy = e.detail; } + else if (dy == null) { dy = e.wheelDelta; } + return {x: dx, y: dy} + } + function wheelEventPixels(e) { + var delta = wheelEventDelta(e); + delta.x *= wheelPixelsPerUnit; + delta.y *= wheelPixelsPerUnit; + return delta + } + + function onScrollWheel(cm, e) { + // On Chrome 102, viewport updates somehow stop wheel-based + // scrolling. Turning off pointer events during the scroll seems + // to avoid the issue. + if (chrome && chrome_version == 102) { + if (cm.display.chromeScrollHack == null) { cm.display.sizer.style.pointerEvents = "none"; } + else { clearTimeout(cm.display.chromeScrollHack); } + cm.display.chromeScrollHack = setTimeout(function () { + cm.display.chromeScrollHack = null; + cm.display.sizer.style.pointerEvents = ""; + }, 100); + } + var delta = wheelEventDelta(e), dx = delta.x, dy = delta.y; + var pixelsPerUnit = wheelPixelsPerUnit; + if (e.deltaMode === 0) { + dx = e.deltaX; + dy = e.deltaY; + pixelsPerUnit = 1; + } + + var display = cm.display, scroll = display.scroller; + // Quit if there's nothing to scroll here + var canScrollX = scroll.scrollWidth > scroll.clientWidth; + var canScrollY = scroll.scrollHeight > scroll.clientHeight; + if (!(dx && canScrollX || dy && canScrollY)) { return } + + // Webkit browsers on OS X abort momentum scrolls when the target + // of the scroll event is removed from the scrollable element. + // This hack (see related code in patchDisplay) makes sure the + // element is kept around. + if (dy && mac && webkit) { + outer: for (var cur = e.target, view = display.view; cur != scroll; cur = cur.parentNode) { + for (var i = 0; i < view.length; i++) { + if (view[i].node == cur) { + cm.display.currentWheelTarget = cur; + break outer + } + } + } + } + + // On some browsers, horizontal scrolling will cause redraws to + // happen before the gutter has been realigned, causing it to + // wriggle around in a most unseemly way. When we have an + // estimated pixels/delta value, we just handle horizontal + // scrolling entirely here. It'll be slightly off from native, but + // better than glitching out. + if (dx && !gecko && !presto && pixelsPerUnit != null) { + if (dy && canScrollY) + { updateScrollTop(cm, Math.max(0, scroll.scrollTop + dy * pixelsPerUnit)); } + setScrollLeft(cm, Math.max(0, scroll.scrollLeft + dx * pixelsPerUnit)); + // Only prevent default scrolling if vertical scrolling is + // actually possible. Otherwise, it causes vertical scroll + // jitter on OSX trackpads when deltaX is small and deltaY + // is large (issue #3579) + if (!dy || (dy && canScrollY)) + { e_preventDefault(e); } + display.wheelStartX = null; // Abort measurement, if in progress + return + } + + // 'Project' the visible viewport to cover the area that is being + // scrolled into view (if we know enough to estimate it). + if (dy && pixelsPerUnit != null) { + var pixels = dy * pixelsPerUnit; + var top = cm.doc.scrollTop, bot = top + display.wrapper.clientHeight; + if (pixels < 0) { top = Math.max(0, top + pixels - 50); } + else { bot = Math.min(cm.doc.height, bot + pixels + 50); } + updateDisplaySimple(cm, {top: top, bottom: bot}); + } + + if (wheelSamples < 20 && e.deltaMode !== 0) { + if (display.wheelStartX == null) { + display.wheelStartX = scroll.scrollLeft; display.wheelStartY = scroll.scrollTop; + display.wheelDX = dx; display.wheelDY = dy; + setTimeout(function () { + if (display.wheelStartX == null) { return } + var movedX = scroll.scrollLeft - display.wheelStartX; + var movedY = scroll.scrollTop - display.wheelStartY; + var sample = (movedY && display.wheelDY && movedY / display.wheelDY) || + (movedX && display.wheelDX && movedX / display.wheelDX); + display.wheelStartX = display.wheelStartY = null; + if (!sample) { return } + wheelPixelsPerUnit = (wheelPixelsPerUnit * wheelSamples + sample) / (wheelSamples + 1); + ++wheelSamples; + }, 200); + } else { + display.wheelDX += dx; display.wheelDY += dy; + } + } + } + + // Selection objects are immutable. A new one is created every time + // the selection changes. A selection is one or more non-overlapping + // (and non-touching) ranges, sorted, and an integer that indicates + // which one is the primary selection (the one that's scrolled into + // view, that getCursor returns, etc). + var Selection = function(ranges, primIndex) { + this.ranges = ranges; + this.primIndex = primIndex; + }; + + Selection.prototype.primary = function () { return this.ranges[this.primIndex] }; + + Selection.prototype.equals = function (other) { + if (other == this) { return true } + if (other.primIndex != this.primIndex || other.ranges.length != this.ranges.length) { return false } + for (var i = 0; i < this.ranges.length; i++) { + var here = this.ranges[i], there = other.ranges[i]; + if (!equalCursorPos(here.anchor, there.anchor) || !equalCursorPos(here.head, there.head)) { return false } + } + return true + }; + + Selection.prototype.deepCopy = function () { + var out = []; + for (var i = 0; i < this.ranges.length; i++) + { out[i] = new Range(copyPos(this.ranges[i].anchor), copyPos(this.ranges[i].head)); } + return new Selection(out, this.primIndex) + }; + + Selection.prototype.somethingSelected = function () { + for (var i = 0; i < this.ranges.length; i++) + { if (!this.ranges[i].empty()) { return true } } + return false + }; + + Selection.prototype.contains = function (pos, end) { + if (!end) { end = pos; } + for (var i = 0; i < this.ranges.length; i++) { + var range = this.ranges[i]; + if (cmp(end, range.from()) >= 0 && cmp(pos, range.to()) <= 0) + { return i } + } + return -1 + }; + + var Range = function(anchor, head) { + this.anchor = anchor; this.head = head; + }; + + Range.prototype.from = function () { return minPos(this.anchor, this.head) }; + Range.prototype.to = function () { return maxPos(this.anchor, this.head) }; + Range.prototype.empty = function () { return this.head.line == this.anchor.line && this.head.ch == this.anchor.ch }; + + // Take an unsorted, potentially overlapping set of ranges, and + // build a selection out of it. 'Consumes' ranges array (modifying + // it). + function normalizeSelection(cm, ranges, primIndex) { + var mayTouch = cm && cm.options.selectionsMayTouch; + var prim = ranges[primIndex]; + ranges.sort(function (a, b) { return cmp(a.from(), b.from()); }); + primIndex = indexOf(ranges, prim); + for (var i = 1; i < ranges.length; i++) { + var cur = ranges[i], prev = ranges[i - 1]; + var diff = cmp(prev.to(), cur.from()); + if (mayTouch && !cur.empty() ? diff > 0 : diff >= 0) { + var from = minPos(prev.from(), cur.from()), to = maxPos(prev.to(), cur.to()); + var inv = prev.empty() ? cur.from() == cur.head : prev.from() == prev.head; + if (i <= primIndex) { --primIndex; } + ranges.splice(--i, 2, new Range(inv ? to : from, inv ? from : to)); + } + } + return new Selection(ranges, primIndex) + } + + function simpleSelection(anchor, head) { + return new Selection([new Range(anchor, head || anchor)], 0) + } + + // Compute the position of the end of a change (its 'to' property + // refers to the pre-change end). + function changeEnd(change) { + if (!change.text) { return change.to } + return Pos(change.from.line + change.text.length - 1, + lst(change.text).length + (change.text.length == 1 ? change.from.ch : 0)) + } + + // Adjust a position to refer to the post-change position of the + // same text, or the end of the change if the change covers it. + function adjustForChange(pos, change) { + if (cmp(pos, change.from) < 0) { return pos } + if (cmp(pos, change.to) <= 0) { return changeEnd(change) } + + var line = pos.line + change.text.length - (change.to.line - change.from.line) - 1, ch = pos.ch; + if (pos.line == change.to.line) { ch += changeEnd(change).ch - change.to.ch; } + return Pos(line, ch) + } + + function computeSelAfterChange(doc, change) { + var out = []; + for (var i = 0; i < doc.sel.ranges.length; i++) { + var range = doc.sel.ranges[i]; + out.push(new Range(adjustForChange(range.anchor, change), + adjustForChange(range.head, change))); + } + return normalizeSelection(doc.cm, out, doc.sel.primIndex) + } + + function offsetPos(pos, old, nw) { + if (pos.line == old.line) + { return Pos(nw.line, pos.ch - old.ch + nw.ch) } + else + { return Pos(nw.line + (pos.line - old.line), pos.ch) } + } + + // Used by replaceSelections to allow moving the selection to the + // start or around the replaced test. Hint may be "start" or "around". + function computeReplacedSel(doc, changes, hint) { + var out = []; + var oldPrev = Pos(doc.first, 0), newPrev = oldPrev; + for (var i = 0; i < changes.length; i++) { + var change = changes[i]; + var from = offsetPos(change.from, oldPrev, newPrev); + var to = offsetPos(changeEnd(change), oldPrev, newPrev); + oldPrev = change.to; + newPrev = to; + if (hint == "around") { + var range = doc.sel.ranges[i], inv = cmp(range.head, range.anchor) < 0; + out[i] = new Range(inv ? to : from, inv ? from : to); + } else { + out[i] = new Range(from, from); + } + } + return new Selection(out, doc.sel.primIndex) + } + + // Used to get the editor into a consistent state again when options change. + + function loadMode(cm) { + cm.doc.mode = getMode(cm.options, cm.doc.modeOption); + resetModeState(cm); + } + + function resetModeState(cm) { + cm.doc.iter(function (line) { + if (line.stateAfter) { line.stateAfter = null; } + if (line.styles) { line.styles = null; } + }); + cm.doc.modeFrontier = cm.doc.highlightFrontier = cm.doc.first; + startWorker(cm, 100); + cm.state.modeGen++; + if (cm.curOp) { regChange(cm); } + } + + // DOCUMENT DATA STRUCTURE + + // By default, updates that start and end at the beginning of a line + // are treated specially, in order to make the association of line + // widgets and marker elements with the text behave more intuitive. + function isWholeLineUpdate(doc, change) { + return change.from.ch == 0 && change.to.ch == 0 && lst(change.text) == "" && + (!doc.cm || doc.cm.options.wholeLineUpdateBefore) + } + + // Perform a change on the document data structure. + function updateDoc(doc, change, markedSpans, estimateHeight) { + function spansFor(n) {return markedSpans ? markedSpans[n] : null} + function update(line, text, spans) { + updateLine(line, text, spans, estimateHeight); + signalLater(line, "change", line, change); + } + function linesFor(start, end) { + var result = []; + for (var i = start; i < end; ++i) + { result.push(new Line(text[i], spansFor(i), estimateHeight)); } + return result + } + + var from = change.from, to = change.to, text = change.text; + var firstLine = getLine(doc, from.line), lastLine = getLine(doc, to.line); + var lastText = lst(text), lastSpans = spansFor(text.length - 1), nlines = to.line - from.line; + + // Adjust the line structure + if (change.full) { + doc.insert(0, linesFor(0, text.length)); + doc.remove(text.length, doc.size - text.length); + } else if (isWholeLineUpdate(doc, change)) { + // This is a whole-line replace. Treated specially to make + // sure line objects move the way they are supposed to. + var added = linesFor(0, text.length - 1); + update(lastLine, lastLine.text, lastSpans); + if (nlines) { doc.remove(from.line, nlines); } + if (added.length) { doc.insert(from.line, added); } + } else if (firstLine == lastLine) { + if (text.length == 1) { + update(firstLine, firstLine.text.slice(0, from.ch) + lastText + firstLine.text.slice(to.ch), lastSpans); + } else { + var added$1 = linesFor(1, text.length - 1); + added$1.push(new Line(lastText + firstLine.text.slice(to.ch), lastSpans, estimateHeight)); + update(firstLine, firstLine.text.slice(0, from.ch) + text[0], spansFor(0)); + doc.insert(from.line + 1, added$1); + } + } else if (text.length == 1) { + update(firstLine, firstLine.text.slice(0, from.ch) + text[0] + lastLine.text.slice(to.ch), spansFor(0)); + doc.remove(from.line + 1, nlines); + } else { + update(firstLine, firstLine.text.slice(0, from.ch) + text[0], spansFor(0)); + update(lastLine, lastText + lastLine.text.slice(to.ch), lastSpans); + var added$2 = linesFor(1, text.length - 1); + if (nlines > 1) { doc.remove(from.line + 1, nlines - 1); } + doc.insert(from.line + 1, added$2); + } + + signalLater(doc, "change", doc, change); + } + + // Call f for all linked documents. + function linkedDocs(doc, f, sharedHistOnly) { + function propagate(doc, skip, sharedHist) { + if (doc.linked) { for (var i = 0; i < doc.linked.length; ++i) { + var rel = doc.linked[i]; + if (rel.doc == skip) { continue } + var shared = sharedHist && rel.sharedHist; + if (sharedHistOnly && !shared) { continue } + f(rel.doc, shared); + propagate(rel.doc, doc, shared); + } } + } + propagate(doc, null, true); + } + + // Attach a document to an editor. + function attachDoc(cm, doc) { + if (doc.cm) { throw new Error("This document is already in use.") } + cm.doc = doc; + doc.cm = cm; + estimateLineHeights(cm); + loadMode(cm); + setDirectionClass(cm); + cm.options.direction = doc.direction; + if (!cm.options.lineWrapping) { findMaxLine(cm); } + cm.options.mode = doc.modeOption; + regChange(cm); + } + + function setDirectionClass(cm) { + (cm.doc.direction == "rtl" ? addClass : rmClass)(cm.display.lineDiv, "CodeMirror-rtl"); + } + + function directionChanged(cm) { + runInOp(cm, function () { + setDirectionClass(cm); + regChange(cm); + }); + } + + function History(prev) { + // Arrays of change events and selections. Doing something adds an + // event to done and clears undo. Undoing moves events from done + // to undone, redoing moves them in the other direction. + this.done = []; this.undone = []; + this.undoDepth = prev ? prev.undoDepth : Infinity; + // Used to track when changes can be merged into a single undo + // event + this.lastModTime = this.lastSelTime = 0; + this.lastOp = this.lastSelOp = null; + this.lastOrigin = this.lastSelOrigin = null; + // Used by the isClean() method + this.generation = this.maxGeneration = prev ? prev.maxGeneration : 1; + } + + // Create a history change event from an updateDoc-style change + // object. + function historyChangeFromChange(doc, change) { + var histChange = {from: copyPos(change.from), to: changeEnd(change), text: getBetween(doc, change.from, change.to)}; + attachLocalSpans(doc, histChange, change.from.line, change.to.line + 1); + linkedDocs(doc, function (doc) { return attachLocalSpans(doc, histChange, change.from.line, change.to.line + 1); }, true); + return histChange + } + + // Pop all selection events off the end of a history array. Stop at + // a change event. + function clearSelectionEvents(array) { + while (array.length) { + var last = lst(array); + if (last.ranges) { array.pop(); } + else { break } + } + } + + // Find the top change event in the history. Pop off selection + // events that are in the way. + function lastChangeEvent(hist, force) { + if (force) { + clearSelectionEvents(hist.done); + return lst(hist.done) + } else if (hist.done.length && !lst(hist.done).ranges) { + return lst(hist.done) + } else if (hist.done.length > 1 && !hist.done[hist.done.length - 2].ranges) { + hist.done.pop(); + return lst(hist.done) + } + } + + // Register a change in the history. Merges changes that are within + // a single operation, or are close together with an origin that + // allows merging (starting with "+") into a single event. + function addChangeToHistory(doc, change, selAfter, opId) { + var hist = doc.history; + hist.undone.length = 0; + var time = +new Date, cur; + var last; + + if ((hist.lastOp == opId || + hist.lastOrigin == change.origin && change.origin && + ((change.origin.charAt(0) == "+" && hist.lastModTime > time - (doc.cm ? doc.cm.options.historyEventDelay : 500)) || + change.origin.charAt(0) == "*")) && + (cur = lastChangeEvent(hist, hist.lastOp == opId))) { + // Merge this change into the last event + last = lst(cur.changes); + if (cmp(change.from, change.to) == 0 && cmp(change.from, last.to) == 0) { + // Optimized case for simple insertion -- don't want to add + // new changesets for every character typed + last.to = changeEnd(change); + } else { + // Add new sub-event + cur.changes.push(historyChangeFromChange(doc, change)); + } + } else { + // Can not be merged, start a new event. + var before = lst(hist.done); + if (!before || !before.ranges) + { pushSelectionToHistory(doc.sel, hist.done); } + cur = {changes: [historyChangeFromChange(doc, change)], + generation: hist.generation}; + hist.done.push(cur); + while (hist.done.length > hist.undoDepth) { + hist.done.shift(); + if (!hist.done[0].ranges) { hist.done.shift(); } + } + } + hist.done.push(selAfter); + hist.generation = ++hist.maxGeneration; + hist.lastModTime = hist.lastSelTime = time; + hist.lastOp = hist.lastSelOp = opId; + hist.lastOrigin = hist.lastSelOrigin = change.origin; + + if (!last) { signal(doc, "historyAdded"); } + } + + function selectionEventCanBeMerged(doc, origin, prev, sel) { + var ch = origin.charAt(0); + return ch == "*" || + ch == "+" && + prev.ranges.length == sel.ranges.length && + prev.somethingSelected() == sel.somethingSelected() && + new Date - doc.history.lastSelTime <= (doc.cm ? doc.cm.options.historyEventDelay : 500) + } + + // Called whenever the selection changes, sets the new selection as + // the pending selection in the history, and pushes the old pending + // selection into the 'done' array when it was significantly + // different (in number of selected ranges, emptiness, or time). + function addSelectionToHistory(doc, sel, opId, options) { + var hist = doc.history, origin = options && options.origin; + + // A new event is started when the previous origin does not match + // the current, or the origins don't allow matching. Origins + // starting with * are always merged, those starting with + are + // merged when similar and close together in time. + if (opId == hist.lastSelOp || + (origin && hist.lastSelOrigin == origin && + (hist.lastModTime == hist.lastSelTime && hist.lastOrigin == origin || + selectionEventCanBeMerged(doc, origin, lst(hist.done), sel)))) + { hist.done[hist.done.length - 1] = sel; } + else + { pushSelectionToHistory(sel, hist.done); } + + hist.lastSelTime = +new Date; + hist.lastSelOrigin = origin; + hist.lastSelOp = opId; + if (options && options.clearRedo !== false) + { clearSelectionEvents(hist.undone); } + } + + function pushSelectionToHistory(sel, dest) { + var top = lst(dest); + if (!(top && top.ranges && top.equals(sel))) + { dest.push(sel); } + } + + // Used to store marked span information in the history. + function attachLocalSpans(doc, change, from, to) { + var existing = change["spans_" + doc.id], n = 0; + doc.iter(Math.max(doc.first, from), Math.min(doc.first + doc.size, to), function (line) { + if (line.markedSpans) + { (existing || (existing = change["spans_" + doc.id] = {}))[n] = line.markedSpans; } + ++n; + }); + } + + // When un/re-doing restores text containing marked spans, those + // that have been explicitly cleared should not be restored. + function removeClearedSpans(spans) { + if (!spans) { return null } + var out; + for (var i = 0; i < spans.length; ++i) { + if (spans[i].marker.explicitlyCleared) { if (!out) { out = spans.slice(0, i); } } + else if (out) { out.push(spans[i]); } + } + return !out ? spans : out.length ? out : null + } + + // Retrieve and filter the old marked spans stored in a change event. + function getOldSpans(doc, change) { + var found = change["spans_" + doc.id]; + if (!found) { return null } + var nw = []; + for (var i = 0; i < change.text.length; ++i) + { nw.push(removeClearedSpans(found[i])); } + return nw + } + + // Used for un/re-doing changes from the history. Combines the + // result of computing the existing spans with the set of spans that + // existed in the history (so that deleting around a span and then + // undoing brings back the span). + function mergeOldSpans(doc, change) { + var old = getOldSpans(doc, change); + var stretched = stretchSpansOverChange(doc, change); + if (!old) { return stretched } + if (!stretched) { return old } + + for (var i = 0; i < old.length; ++i) { + var oldCur = old[i], stretchCur = stretched[i]; + if (oldCur && stretchCur) { + spans: for (var j = 0; j < stretchCur.length; ++j) { + var span = stretchCur[j]; + for (var k = 0; k < oldCur.length; ++k) + { if (oldCur[k].marker == span.marker) { continue spans } } + oldCur.push(span); + } + } else if (stretchCur) { + old[i] = stretchCur; + } + } + return old + } + + // Used both to provide a JSON-safe object in .getHistory, and, when + // detaching a document, to split the history in two + function copyHistoryArray(events, newGroup, instantiateSel) { + var copy = []; + for (var i = 0; i < events.length; ++i) { + var event = events[i]; + if (event.ranges) { + copy.push(instantiateSel ? Selection.prototype.deepCopy.call(event) : event); + continue + } + var changes = event.changes, newChanges = []; + copy.push({changes: newChanges}); + for (var j = 0; j < changes.length; ++j) { + var change = changes[j], m = (void 0); + newChanges.push({from: change.from, to: change.to, text: change.text}); + if (newGroup) { for (var prop in change) { if (m = prop.match(/^spans_(\d+)$/)) { + if (indexOf(newGroup, Number(m[1])) > -1) { + lst(newChanges)[prop] = change[prop]; + delete change[prop]; + } + } } } + } + } + return copy + } + + // The 'scroll' parameter given to many of these indicated whether + // the new cursor position should be scrolled into view after + // modifying the selection. + + // If shift is held or the extend flag is set, extends a range to + // include a given position (and optionally a second position). + // Otherwise, simply returns the range between the given positions. + // Used for cursor motion and such. + function extendRange(range, head, other, extend) { + if (extend) { + var anchor = range.anchor; + if (other) { + var posBefore = cmp(head, anchor) < 0; + if (posBefore != (cmp(other, anchor) < 0)) { + anchor = head; + head = other; + } else if (posBefore != (cmp(head, other) < 0)) { + head = other; + } + } + return new Range(anchor, head) + } else { + return new Range(other || head, head) + } + } + + // Extend the primary selection range, discard the rest. + function extendSelection(doc, head, other, options, extend) { + if (extend == null) { extend = doc.cm && (doc.cm.display.shift || doc.extend); } + setSelection(doc, new Selection([extendRange(doc.sel.primary(), head, other, extend)], 0), options); + } + + // Extend all selections (pos is an array of selections with length + // equal the number of selections) + function extendSelections(doc, heads, options) { + var out = []; + var extend = doc.cm && (doc.cm.display.shift || doc.extend); + for (var i = 0; i < doc.sel.ranges.length; i++) + { out[i] = extendRange(doc.sel.ranges[i], heads[i], null, extend); } + var newSel = normalizeSelection(doc.cm, out, doc.sel.primIndex); + setSelection(doc, newSel, options); + } + + // Updates a single range in the selection. + function replaceOneSelection(doc, i, range, options) { + var ranges = doc.sel.ranges.slice(0); + ranges[i] = range; + setSelection(doc, normalizeSelection(doc.cm, ranges, doc.sel.primIndex), options); + } + + // Reset the selection to a single range. + function setSimpleSelection(doc, anchor, head, options) { + setSelection(doc, simpleSelection(anchor, head), options); + } + + // Give beforeSelectionChange handlers a change to influence a + // selection update. + function filterSelectionChange(doc, sel, options) { + var obj = { + ranges: sel.ranges, + update: function(ranges) { + this.ranges = []; + for (var i = 0; i < ranges.length; i++) + { this.ranges[i] = new Range(clipPos(doc, ranges[i].anchor), + clipPos(doc, ranges[i].head)); } + }, + origin: options && options.origin + }; + signal(doc, "beforeSelectionChange", doc, obj); + if (doc.cm) { signal(doc.cm, "beforeSelectionChange", doc.cm, obj); } + if (obj.ranges != sel.ranges) { return normalizeSelection(doc.cm, obj.ranges, obj.ranges.length - 1) } + else { return sel } + } + + function setSelectionReplaceHistory(doc, sel, options) { + var done = doc.history.done, last = lst(done); + if (last && last.ranges) { + done[done.length - 1] = sel; + setSelectionNoUndo(doc, sel, options); + } else { + setSelection(doc, sel, options); + } + } + + // Set a new selection. + function setSelection(doc, sel, options) { + setSelectionNoUndo(doc, sel, options); + addSelectionToHistory(doc, doc.sel, doc.cm ? doc.cm.curOp.id : NaN, options); + } + + function setSelectionNoUndo(doc, sel, options) { + if (hasHandler(doc, "beforeSelectionChange") || doc.cm && hasHandler(doc.cm, "beforeSelectionChange")) + { sel = filterSelectionChange(doc, sel, options); } + + var bias = options && options.bias || + (cmp(sel.primary().head, doc.sel.primary().head) < 0 ? -1 : 1); + setSelectionInner(doc, skipAtomicInSelection(doc, sel, bias, true)); + + if (!(options && options.scroll === false) && doc.cm && doc.cm.getOption("readOnly") != "nocursor") + { ensureCursorVisible(doc.cm); } + } + + function setSelectionInner(doc, sel) { + if (sel.equals(doc.sel)) { return } + + doc.sel = sel; + + if (doc.cm) { + doc.cm.curOp.updateInput = 1; + doc.cm.curOp.selectionChanged = true; + signalCursorActivity(doc.cm); + } + signalLater(doc, "cursorActivity", doc); + } + + // Verify that the selection does not partially select any atomic + // marked ranges. + function reCheckSelection(doc) { + setSelectionInner(doc, skipAtomicInSelection(doc, doc.sel, null, false)); + } + + // Return a selection that does not partially select any atomic + // ranges. + function skipAtomicInSelection(doc, sel, bias, mayClear) { + var out; + for (var i = 0; i < sel.ranges.length; i++) { + var range = sel.ranges[i]; + var old = sel.ranges.length == doc.sel.ranges.length && doc.sel.ranges[i]; + var newAnchor = skipAtomic(doc, range.anchor, old && old.anchor, bias, mayClear); + var newHead = range.head == range.anchor ? newAnchor : skipAtomic(doc, range.head, old && old.head, bias, mayClear); + if (out || newAnchor != range.anchor || newHead != range.head) { + if (!out) { out = sel.ranges.slice(0, i); } + out[i] = new Range(newAnchor, newHead); + } + } + return out ? normalizeSelection(doc.cm, out, sel.primIndex) : sel + } + + function skipAtomicInner(doc, pos, oldPos, dir, mayClear) { + var line = getLine(doc, pos.line); + if (line.markedSpans) { for (var i = 0; i < line.markedSpans.length; ++i) { + var sp = line.markedSpans[i], m = sp.marker; + + // Determine if we should prevent the cursor being placed to the left/right of an atomic marker + // Historically this was determined using the inclusiveLeft/Right option, but the new way to control it + // is with selectLeft/Right + var preventCursorLeft = ("selectLeft" in m) ? !m.selectLeft : m.inclusiveLeft; + var preventCursorRight = ("selectRight" in m) ? !m.selectRight : m.inclusiveRight; + + if ((sp.from == null || (preventCursorLeft ? sp.from <= pos.ch : sp.from < pos.ch)) && + (sp.to == null || (preventCursorRight ? sp.to >= pos.ch : sp.to > pos.ch))) { + if (mayClear) { + signal(m, "beforeCursorEnter"); + if (m.explicitlyCleared) { + if (!line.markedSpans) { break } + else {--i; continue} + } + } + if (!m.atomic) { continue } + + if (oldPos) { + var near = m.find(dir < 0 ? 1 : -1), diff = (void 0); + if (dir < 0 ? preventCursorRight : preventCursorLeft) + { near = movePos(doc, near, -dir, near && near.line == pos.line ? line : null); } + if (near && near.line == pos.line && (diff = cmp(near, oldPos)) && (dir < 0 ? diff < 0 : diff > 0)) + { return skipAtomicInner(doc, near, pos, dir, mayClear) } + } + + var far = m.find(dir < 0 ? -1 : 1); + if (dir < 0 ? preventCursorLeft : preventCursorRight) + { far = movePos(doc, far, dir, far.line == pos.line ? line : null); } + return far ? skipAtomicInner(doc, far, pos, dir, mayClear) : null + } + } } + return pos + } + + // Ensure a given position is not inside an atomic range. + function skipAtomic(doc, pos, oldPos, bias, mayClear) { + var dir = bias || 1; + var found = skipAtomicInner(doc, pos, oldPos, dir, mayClear) || + (!mayClear && skipAtomicInner(doc, pos, oldPos, dir, true)) || + skipAtomicInner(doc, pos, oldPos, -dir, mayClear) || + (!mayClear && skipAtomicInner(doc, pos, oldPos, -dir, true)); + if (!found) { + doc.cantEdit = true; + return Pos(doc.first, 0) + } + return found + } + + function movePos(doc, pos, dir, line) { + if (dir < 0 && pos.ch == 0) { + if (pos.line > doc.first) { return clipPos(doc, Pos(pos.line - 1)) } + else { return null } + } else if (dir > 0 && pos.ch == (line || getLine(doc, pos.line)).text.length) { + if (pos.line < doc.first + doc.size - 1) { return Pos(pos.line + 1, 0) } + else { return null } + } else { + return new Pos(pos.line, pos.ch + dir) + } + } + + function selectAll(cm) { + cm.setSelection(Pos(cm.firstLine(), 0), Pos(cm.lastLine()), sel_dontScroll); + } + + // UPDATING + + // Allow "beforeChange" event handlers to influence a change + function filterChange(doc, change, update) { + var obj = { + canceled: false, + from: change.from, + to: change.to, + text: change.text, + origin: change.origin, + cancel: function () { return obj.canceled = true; } + }; + if (update) { obj.update = function (from, to, text, origin) { + if (from) { obj.from = clipPos(doc, from); } + if (to) { obj.to = clipPos(doc, to); } + if (text) { obj.text = text; } + if (origin !== undefined) { obj.origin = origin; } + }; } + signal(doc, "beforeChange", doc, obj); + if (doc.cm) { signal(doc.cm, "beforeChange", doc.cm, obj); } + + if (obj.canceled) { + if (doc.cm) { doc.cm.curOp.updateInput = 2; } + return null + } + return {from: obj.from, to: obj.to, text: obj.text, origin: obj.origin} + } + + // Apply a change to a document, and add it to the document's + // history, and propagating it to all linked documents. + function makeChange(doc, change, ignoreReadOnly) { + if (doc.cm) { + if (!doc.cm.curOp) { return operation(doc.cm, makeChange)(doc, change, ignoreReadOnly) } + if (doc.cm.state.suppressEdits) { return } + } + + if (hasHandler(doc, "beforeChange") || doc.cm && hasHandler(doc.cm, "beforeChange")) { + change = filterChange(doc, change, true); + if (!change) { return } + } + + // Possibly split or suppress the update based on the presence + // of read-only spans in its range. + var split = sawReadOnlySpans && !ignoreReadOnly && removeReadOnlyRanges(doc, change.from, change.to); + if (split) { + for (var i = split.length - 1; i >= 0; --i) + { makeChangeInner(doc, {from: split[i].from, to: split[i].to, text: i ? [""] : change.text, origin: change.origin}); } + } else { + makeChangeInner(doc, change); + } + } + + function makeChangeInner(doc, change) { + if (change.text.length == 1 && change.text[0] == "" && cmp(change.from, change.to) == 0) { return } + var selAfter = computeSelAfterChange(doc, change); + addChangeToHistory(doc, change, selAfter, doc.cm ? doc.cm.curOp.id : NaN); + + makeChangeSingleDoc(doc, change, selAfter, stretchSpansOverChange(doc, change)); + var rebased = []; + + linkedDocs(doc, function (doc, sharedHist) { + if (!sharedHist && indexOf(rebased, doc.history) == -1) { + rebaseHist(doc.history, change); + rebased.push(doc.history); + } + makeChangeSingleDoc(doc, change, null, stretchSpansOverChange(doc, change)); + }); + } + + // Revert a change stored in a document's history. + function makeChangeFromHistory(doc, type, allowSelectionOnly) { + var suppress = doc.cm && doc.cm.state.suppressEdits; + if (suppress && !allowSelectionOnly) { return } + + var hist = doc.history, event, selAfter = doc.sel; + var source = type == "undo" ? hist.done : hist.undone, dest = type == "undo" ? hist.undone : hist.done; + + // Verify that there is a useable event (so that ctrl-z won't + // needlessly clear selection events) + var i = 0; + for (; i < source.length; i++) { + event = source[i]; + if (allowSelectionOnly ? event.ranges && !event.equals(doc.sel) : !event.ranges) + { break } + } + if (i == source.length) { return } + hist.lastOrigin = hist.lastSelOrigin = null; + + for (;;) { + event = source.pop(); + if (event.ranges) { + pushSelectionToHistory(event, dest); + if (allowSelectionOnly && !event.equals(doc.sel)) { + setSelection(doc, event, {clearRedo: false}); + return + } + selAfter = event; + } else if (suppress) { + source.push(event); + return + } else { break } + } + + // Build up a reverse change object to add to the opposite history + // stack (redo when undoing, and vice versa). + var antiChanges = []; + pushSelectionToHistory(selAfter, dest); + dest.push({changes: antiChanges, generation: hist.generation}); + hist.generation = event.generation || ++hist.maxGeneration; + + var filter = hasHandler(doc, "beforeChange") || doc.cm && hasHandler(doc.cm, "beforeChange"); + + var loop = function ( i ) { + var change = event.changes[i]; + change.origin = type; + if (filter && !filterChange(doc, change, false)) { + source.length = 0; + return {} + } + + antiChanges.push(historyChangeFromChange(doc, change)); + + var after = i ? computeSelAfterChange(doc, change) : lst(source); + makeChangeSingleDoc(doc, change, after, mergeOldSpans(doc, change)); + if (!i && doc.cm) { doc.cm.scrollIntoView({from: change.from, to: changeEnd(change)}); } + var rebased = []; + + // Propagate to the linked documents + linkedDocs(doc, function (doc, sharedHist) { + if (!sharedHist && indexOf(rebased, doc.history) == -1) { + rebaseHist(doc.history, change); + rebased.push(doc.history); + } + makeChangeSingleDoc(doc, change, null, mergeOldSpans(doc, change)); + }); + }; + + for (var i$1 = event.changes.length - 1; i$1 >= 0; --i$1) { + var returned = loop( i$1 ); + + if ( returned ) return returned.v; + } + } + + // Sub-views need their line numbers shifted when text is added + // above or below them in the parent document. + function shiftDoc(doc, distance) { + if (distance == 0) { return } + doc.first += distance; + doc.sel = new Selection(map(doc.sel.ranges, function (range) { return new Range( + Pos(range.anchor.line + distance, range.anchor.ch), + Pos(range.head.line + distance, range.head.ch) + ); }), doc.sel.primIndex); + if (doc.cm) { + regChange(doc.cm, doc.first, doc.first - distance, distance); + for (var d = doc.cm.display, l = d.viewFrom; l < d.viewTo; l++) + { regLineChange(doc.cm, l, "gutter"); } + } + } + + // More lower-level change function, handling only a single document + // (not linked ones). + function makeChangeSingleDoc(doc, change, selAfter, spans) { + if (doc.cm && !doc.cm.curOp) + { return operation(doc.cm, makeChangeSingleDoc)(doc, change, selAfter, spans) } + + if (change.to.line < doc.first) { + shiftDoc(doc, change.text.length - 1 - (change.to.line - change.from.line)); + return + } + if (change.from.line > doc.lastLine()) { return } + + // Clip the change to the size of this doc + if (change.from.line < doc.first) { + var shift = change.text.length - 1 - (doc.first - change.from.line); + shiftDoc(doc, shift); + change = {from: Pos(doc.first, 0), to: Pos(change.to.line + shift, change.to.ch), + text: [lst(change.text)], origin: change.origin}; + } + var last = doc.lastLine(); + if (change.to.line > last) { + change = {from: change.from, to: Pos(last, getLine(doc, last).text.length), + text: [change.text[0]], origin: change.origin}; + } + + change.removed = getBetween(doc, change.from, change.to); + + if (!selAfter) { selAfter = computeSelAfterChange(doc, change); } + if (doc.cm) { makeChangeSingleDocInEditor(doc.cm, change, spans); } + else { updateDoc(doc, change, spans); } + setSelectionNoUndo(doc, selAfter, sel_dontScroll); + + if (doc.cantEdit && skipAtomic(doc, Pos(doc.firstLine(), 0))) + { doc.cantEdit = false; } + } + + // Handle the interaction of a change to a document with the editor + // that this document is part of. + function makeChangeSingleDocInEditor(cm, change, spans) { + var doc = cm.doc, display = cm.display, from = change.from, to = change.to; + + var recomputeMaxLength = false, checkWidthStart = from.line; + if (!cm.options.lineWrapping) { + checkWidthStart = lineNo(visualLine(getLine(doc, from.line))); + doc.iter(checkWidthStart, to.line + 1, function (line) { + if (line == display.maxLine) { + recomputeMaxLength = true; + return true + } + }); + } + + if (doc.sel.contains(change.from, change.to) > -1) + { signalCursorActivity(cm); } + + updateDoc(doc, change, spans, estimateHeight(cm)); + + if (!cm.options.lineWrapping) { + doc.iter(checkWidthStart, from.line + change.text.length, function (line) { + var len = lineLength(line); + if (len > display.maxLineLength) { + display.maxLine = line; + display.maxLineLength = len; + display.maxLineChanged = true; + recomputeMaxLength = false; + } + }); + if (recomputeMaxLength) { cm.curOp.updateMaxLine = true; } + } + + retreatFrontier(doc, from.line); + startWorker(cm, 400); + + var lendiff = change.text.length - (to.line - from.line) - 1; + // Remember that these lines changed, for updating the display + if (change.full) + { regChange(cm); } + else if (from.line == to.line && change.text.length == 1 && !isWholeLineUpdate(cm.doc, change)) + { regLineChange(cm, from.line, "text"); } + else + { regChange(cm, from.line, to.line + 1, lendiff); } + + var changesHandler = hasHandler(cm, "changes"), changeHandler = hasHandler(cm, "change"); + if (changeHandler || changesHandler) { + var obj = { + from: from, to: to, + text: change.text, + removed: change.removed, + origin: change.origin + }; + if (changeHandler) { signalLater(cm, "change", cm, obj); } + if (changesHandler) { (cm.curOp.changeObjs || (cm.curOp.changeObjs = [])).push(obj); } + } + cm.display.selForContextMenu = null; + } + + function replaceRange(doc, code, from, to, origin) { + var assign; + + if (!to) { to = from; } + if (cmp(to, from) < 0) { (assign = [to, from], from = assign[0], to = assign[1]); } + if (typeof code == "string") { code = doc.splitLines(code); } + makeChange(doc, {from: from, to: to, text: code, origin: origin}); + } + + // Rebasing/resetting history to deal with externally-sourced changes + + function rebaseHistSelSingle(pos, from, to, diff) { + if (to < pos.line) { + pos.line += diff; + } else if (from < pos.line) { + pos.line = from; + pos.ch = 0; + } + } + + // Tries to rebase an array of history events given a change in the + // document. If the change touches the same lines as the event, the + // event, and everything 'behind' it, is discarded. If the change is + // before the event, the event's positions are updated. Uses a + // copy-on-write scheme for the positions, to avoid having to + // reallocate them all on every rebase, but also avoid problems with + // shared position objects being unsafely updated. + function rebaseHistArray(array, from, to, diff) { + for (var i = 0; i < array.length; ++i) { + var sub = array[i], ok = true; + if (sub.ranges) { + if (!sub.copied) { sub = array[i] = sub.deepCopy(); sub.copied = true; } + for (var j = 0; j < sub.ranges.length; j++) { + rebaseHistSelSingle(sub.ranges[j].anchor, from, to, diff); + rebaseHistSelSingle(sub.ranges[j].head, from, to, diff); + } + continue + } + for (var j$1 = 0; j$1 < sub.changes.length; ++j$1) { + var cur = sub.changes[j$1]; + if (to < cur.from.line) { + cur.from = Pos(cur.from.line + diff, cur.from.ch); + cur.to = Pos(cur.to.line + diff, cur.to.ch); + } else if (from <= cur.to.line) { + ok = false; + break + } + } + if (!ok) { + array.splice(0, i + 1); + i = 0; + } + } + } + + function rebaseHist(hist, change) { + var from = change.from.line, to = change.to.line, diff = change.text.length - (to - from) - 1; + rebaseHistArray(hist.done, from, to, diff); + rebaseHistArray(hist.undone, from, to, diff); + } + + // Utility for applying a change to a line by handle or number, + // returning the number and optionally registering the line as + // changed. + function changeLine(doc, handle, changeType, op) { + var no = handle, line = handle; + if (typeof handle == "number") { line = getLine(doc, clipLine(doc, handle)); } + else { no = lineNo(handle); } + if (no == null) { return null } + if (op(line, no) && doc.cm) { regLineChange(doc.cm, no, changeType); } + return line + } + + // The document is represented as a BTree consisting of leaves, with + // chunk of lines in them, and branches, with up to ten leaves or + // other branch nodes below them. The top node is always a branch + // node, and is the document object itself (meaning it has + // additional methods and properties). + // + // All nodes have parent links. The tree is used both to go from + // line numbers to line objects, and to go from objects to numbers. + // It also indexes by height, and is used to convert between height + // and line object, and to find the total height of the document. + // + // See also http://marijnhaverbeke.nl/blog/codemirror-line-tree.html + + function LeafChunk(lines) { + this.lines = lines; + this.parent = null; + var height = 0; + for (var i = 0; i < lines.length; ++i) { + lines[i].parent = this; + height += lines[i].height; + } + this.height = height; + } + + LeafChunk.prototype = { + chunkSize: function() { return this.lines.length }, + + // Remove the n lines at offset 'at'. + removeInner: function(at, n) { + for (var i = at, e = at + n; i < e; ++i) { + var line = this.lines[i]; + this.height -= line.height; + cleanUpLine(line); + signalLater(line, "delete"); + } + this.lines.splice(at, n); + }, + + // Helper used to collapse a small branch into a single leaf. + collapse: function(lines) { + lines.push.apply(lines, this.lines); + }, + + // Insert the given array of lines at offset 'at', count them as + // having the given height. + insertInner: function(at, lines, height) { + this.height += height; + this.lines = this.lines.slice(0, at).concat(lines).concat(this.lines.slice(at)); + for (var i = 0; i < lines.length; ++i) { lines[i].parent = this; } + }, + + // Used to iterate over a part of the tree. + iterN: function(at, n, op) { + for (var e = at + n; at < e; ++at) + { if (op(this.lines[at])) { return true } } + } + }; + + function BranchChunk(children) { + this.children = children; + var size = 0, height = 0; + for (var i = 0; i < children.length; ++i) { + var ch = children[i]; + size += ch.chunkSize(); height += ch.height; + ch.parent = this; + } + this.size = size; + this.height = height; + this.parent = null; + } + + BranchChunk.prototype = { + chunkSize: function() { return this.size }, + + removeInner: function(at, n) { + this.size -= n; + for (var i = 0; i < this.children.length; ++i) { + var child = this.children[i], sz = child.chunkSize(); + if (at < sz) { + var rm = Math.min(n, sz - at), oldHeight = child.height; + child.removeInner(at, rm); + this.height -= oldHeight - child.height; + if (sz == rm) { this.children.splice(i--, 1); child.parent = null; } + if ((n -= rm) == 0) { break } + at = 0; + } else { at -= sz; } + } + // If the result is smaller than 25 lines, ensure that it is a + // single leaf node. + if (this.size - n < 25 && + (this.children.length > 1 || !(this.children[0] instanceof LeafChunk))) { + var lines = []; + this.collapse(lines); + this.children = [new LeafChunk(lines)]; + this.children[0].parent = this; + } + }, + + collapse: function(lines) { + for (var i = 0; i < this.children.length; ++i) { this.children[i].collapse(lines); } + }, + + insertInner: function(at, lines, height) { + this.size += lines.length; + this.height += height; + for (var i = 0; i < this.children.length; ++i) { + var child = this.children[i], sz = child.chunkSize(); + if (at <= sz) { + child.insertInner(at, lines, height); + if (child.lines && child.lines.length > 50) { + // To avoid memory thrashing when child.lines is huge (e.g. first view of a large file), it's never spliced. + // Instead, small slices are taken. They're taken in order because sequential memory accesses are fastest. + var remaining = child.lines.length % 25 + 25; + for (var pos = remaining; pos < child.lines.length;) { + var leaf = new LeafChunk(child.lines.slice(pos, pos += 25)); + child.height -= leaf.height; + this.children.splice(++i, 0, leaf); + leaf.parent = this; + } + child.lines = child.lines.slice(0, remaining); + this.maybeSpill(); + } + break + } + at -= sz; + } + }, + + // When a node has grown, check whether it should be split. + maybeSpill: function() { + if (this.children.length <= 10) { return } + var me = this; + do { + var spilled = me.children.splice(me.children.length - 5, 5); + var sibling = new BranchChunk(spilled); + if (!me.parent) { // Become the parent node + var copy = new BranchChunk(me.children); + copy.parent = me; + me.children = [copy, sibling]; + me = copy; + } else { + me.size -= sibling.size; + me.height -= sibling.height; + var myIndex = indexOf(me.parent.children, me); + me.parent.children.splice(myIndex + 1, 0, sibling); + } + sibling.parent = me.parent; + } while (me.children.length > 10) + me.parent.maybeSpill(); + }, + + iterN: function(at, n, op) { + for (var i = 0; i < this.children.length; ++i) { + var child = this.children[i], sz = child.chunkSize(); + if (at < sz) { + var used = Math.min(n, sz - at); + if (child.iterN(at, used, op)) { return true } + if ((n -= used) == 0) { break } + at = 0; + } else { at -= sz; } + } + } + }; + + // Line widgets are block elements displayed above or below a line. + + var LineWidget = function(doc, node, options) { + if (options) { for (var opt in options) { if (options.hasOwnProperty(opt)) + { this[opt] = options[opt]; } } } + this.doc = doc; + this.node = node; + }; + + LineWidget.prototype.clear = function () { + var cm = this.doc.cm, ws = this.line.widgets, line = this.line, no = lineNo(line); + if (no == null || !ws) { return } + for (var i = 0; i < ws.length; ++i) { if (ws[i] == this) { ws.splice(i--, 1); } } + if (!ws.length) { line.widgets = null; } + var height = widgetHeight(this); + updateLineHeight(line, Math.max(0, line.height - height)); + if (cm) { + runInOp(cm, function () { + adjustScrollWhenAboveVisible(cm, line, -height); + regLineChange(cm, no, "widget"); + }); + signalLater(cm, "lineWidgetCleared", cm, this, no); + } + }; + + LineWidget.prototype.changed = function () { + var this$1 = this; + + var oldH = this.height, cm = this.doc.cm, line = this.line; + this.height = null; + var diff = widgetHeight(this) - oldH; + if (!diff) { return } + if (!lineIsHidden(this.doc, line)) { updateLineHeight(line, line.height + diff); } + if (cm) { + runInOp(cm, function () { + cm.curOp.forceUpdate = true; + adjustScrollWhenAboveVisible(cm, line, diff); + signalLater(cm, "lineWidgetChanged", cm, this$1, lineNo(line)); + }); + } + }; + eventMixin(LineWidget); + + function adjustScrollWhenAboveVisible(cm, line, diff) { + if (heightAtLine(line) < ((cm.curOp && cm.curOp.scrollTop) || cm.doc.scrollTop)) + { addToScrollTop(cm, diff); } + } + + function addLineWidget(doc, handle, node, options) { + var widget = new LineWidget(doc, node, options); + var cm = doc.cm; + if (cm && widget.noHScroll) { cm.display.alignWidgets = true; } + changeLine(doc, handle, "widget", function (line) { + var widgets = line.widgets || (line.widgets = []); + if (widget.insertAt == null) { widgets.push(widget); } + else { widgets.splice(Math.min(widgets.length, Math.max(0, widget.insertAt)), 0, widget); } + widget.line = line; + if (cm && !lineIsHidden(doc, line)) { + var aboveVisible = heightAtLine(line) < doc.scrollTop; + updateLineHeight(line, line.height + widgetHeight(widget)); + if (aboveVisible) { addToScrollTop(cm, widget.height); } + cm.curOp.forceUpdate = true; + } + return true + }); + if (cm) { signalLater(cm, "lineWidgetAdded", cm, widget, typeof handle == "number" ? handle : lineNo(handle)); } + return widget + } + + // TEXTMARKERS + + // Created with markText and setBookmark methods. A TextMarker is a + // handle that can be used to clear or find a marked position in the + // document. Line objects hold arrays (markedSpans) containing + // {from, to, marker} object pointing to such marker objects, and + // indicating that such a marker is present on that line. Multiple + // lines may point to the same marker when it spans across lines. + // The spans will have null for their from/to properties when the + // marker continues beyond the start/end of the line. Markers have + // links back to the lines they currently touch. + + // Collapsed markers have unique ids, in order to be able to order + // them, which is needed for uniquely determining an outer marker + // when they overlap (they may nest, but not partially overlap). + var nextMarkerId = 0; + + var TextMarker = function(doc, type) { + this.lines = []; + this.type = type; + this.doc = doc; + this.id = ++nextMarkerId; + }; + + // Clear the marker. + TextMarker.prototype.clear = function () { + if (this.explicitlyCleared) { return } + var cm = this.doc.cm, withOp = cm && !cm.curOp; + if (withOp) { startOperation(cm); } + if (hasHandler(this, "clear")) { + var found = this.find(); + if (found) { signalLater(this, "clear", found.from, found.to); } + } + var min = null, max = null; + for (var i = 0; i < this.lines.length; ++i) { + var line = this.lines[i]; + var span = getMarkedSpanFor(line.markedSpans, this); + if (cm && !this.collapsed) { regLineChange(cm, lineNo(line), "text"); } + else if (cm) { + if (span.to != null) { max = lineNo(line); } + if (span.from != null) { min = lineNo(line); } + } + line.markedSpans = removeMarkedSpan(line.markedSpans, span); + if (span.from == null && this.collapsed && !lineIsHidden(this.doc, line) && cm) + { updateLineHeight(line, textHeight(cm.display)); } + } + if (cm && this.collapsed && !cm.options.lineWrapping) { for (var i$1 = 0; i$1 < this.lines.length; ++i$1) { + var visual = visualLine(this.lines[i$1]), len = lineLength(visual); + if (len > cm.display.maxLineLength) { + cm.display.maxLine = visual; + cm.display.maxLineLength = len; + cm.display.maxLineChanged = true; + } + } } + + if (min != null && cm && this.collapsed) { regChange(cm, min, max + 1); } + this.lines.length = 0; + this.explicitlyCleared = true; + if (this.atomic && this.doc.cantEdit) { + this.doc.cantEdit = false; + if (cm) { reCheckSelection(cm.doc); } + } + if (cm) { signalLater(cm, "markerCleared", cm, this, min, max); } + if (withOp) { endOperation(cm); } + if (this.parent) { this.parent.clear(); } + }; + + // Find the position of the marker in the document. Returns a {from, + // to} object by default. Side can be passed to get a specific side + // -- 0 (both), -1 (left), or 1 (right). When lineObj is true, the + // Pos objects returned contain a line object, rather than a line + // number (used to prevent looking up the same line twice). + TextMarker.prototype.find = function (side, lineObj) { + if (side == null && this.type == "bookmark") { side = 1; } + var from, to; + for (var i = 0; i < this.lines.length; ++i) { + var line = this.lines[i]; + var span = getMarkedSpanFor(line.markedSpans, this); + if (span.from != null) { + from = Pos(lineObj ? line : lineNo(line), span.from); + if (side == -1) { return from } + } + if (span.to != null) { + to = Pos(lineObj ? line : lineNo(line), span.to); + if (side == 1) { return to } + } + } + return from && {from: from, to: to} + }; + + // Signals that the marker's widget changed, and surrounding layout + // should be recomputed. + TextMarker.prototype.changed = function () { + var this$1 = this; + + var pos = this.find(-1, true), widget = this, cm = this.doc.cm; + if (!pos || !cm) { return } + runInOp(cm, function () { + var line = pos.line, lineN = lineNo(pos.line); + var view = findViewForLine(cm, lineN); + if (view) { + clearLineMeasurementCacheFor(view); + cm.curOp.selectionChanged = cm.curOp.forceUpdate = true; + } + cm.curOp.updateMaxLine = true; + if (!lineIsHidden(widget.doc, line) && widget.height != null) { + var oldHeight = widget.height; + widget.height = null; + var dHeight = widgetHeight(widget) - oldHeight; + if (dHeight) + { updateLineHeight(line, line.height + dHeight); } + } + signalLater(cm, "markerChanged", cm, this$1); + }); + }; + + TextMarker.prototype.attachLine = function (line) { + if (!this.lines.length && this.doc.cm) { + var op = this.doc.cm.curOp; + if (!op.maybeHiddenMarkers || indexOf(op.maybeHiddenMarkers, this) == -1) + { (op.maybeUnhiddenMarkers || (op.maybeUnhiddenMarkers = [])).push(this); } + } + this.lines.push(line); + }; + + TextMarker.prototype.detachLine = function (line) { + this.lines.splice(indexOf(this.lines, line), 1); + if (!this.lines.length && this.doc.cm) { + var op = this.doc.cm.curOp + ;(op.maybeHiddenMarkers || (op.maybeHiddenMarkers = [])).push(this); + } + }; + eventMixin(TextMarker); + + // Create a marker, wire it up to the right lines, and + function markText(doc, from, to, options, type) { + // Shared markers (across linked documents) are handled separately + // (markTextShared will call out to this again, once per + // document). + if (options && options.shared) { return markTextShared(doc, from, to, options, type) } + // Ensure we are in an operation. + if (doc.cm && !doc.cm.curOp) { return operation(doc.cm, markText)(doc, from, to, options, type) } + + var marker = new TextMarker(doc, type), diff = cmp(from, to); + if (options) { copyObj(options, marker, false); } + // Don't connect empty markers unless clearWhenEmpty is false + if (diff > 0 || diff == 0 && marker.clearWhenEmpty !== false) + { return marker } + if (marker.replacedWith) { + // Showing up as a widget implies collapsed (widget replaces text) + marker.collapsed = true; + marker.widgetNode = eltP("span", [marker.replacedWith], "CodeMirror-widget"); + if (!options.handleMouseEvents) { marker.widgetNode.setAttribute("cm-ignore-events", "true"); } + if (options.insertLeft) { marker.widgetNode.insertLeft = true; } + } + if (marker.collapsed) { + if (conflictingCollapsedRange(doc, from.line, from, to, marker) || + from.line != to.line && conflictingCollapsedRange(doc, to.line, from, to, marker)) + { throw new Error("Inserting collapsed marker partially overlapping an existing one") } + seeCollapsedSpans(); + } + + if (marker.addToHistory) + { addChangeToHistory(doc, {from: from, to: to, origin: "markText"}, doc.sel, NaN); } + + var curLine = from.line, cm = doc.cm, updateMaxLine; + doc.iter(curLine, to.line + 1, function (line) { + if (cm && marker.collapsed && !cm.options.lineWrapping && visualLine(line) == cm.display.maxLine) + { updateMaxLine = true; } + if (marker.collapsed && curLine != from.line) { updateLineHeight(line, 0); } + addMarkedSpan(line, new MarkedSpan(marker, + curLine == from.line ? from.ch : null, + curLine == to.line ? to.ch : null), doc.cm && doc.cm.curOp); + ++curLine; + }); + // lineIsHidden depends on the presence of the spans, so needs a second pass + if (marker.collapsed) { doc.iter(from.line, to.line + 1, function (line) { + if (lineIsHidden(doc, line)) { updateLineHeight(line, 0); } + }); } + + if (marker.clearOnEnter) { on(marker, "beforeCursorEnter", function () { return marker.clear(); }); } + + if (marker.readOnly) { + seeReadOnlySpans(); + if (doc.history.done.length || doc.history.undone.length) + { doc.clearHistory(); } + } + if (marker.collapsed) { + marker.id = ++nextMarkerId; + marker.atomic = true; + } + if (cm) { + // Sync editor state + if (updateMaxLine) { cm.curOp.updateMaxLine = true; } + if (marker.collapsed) + { regChange(cm, from.line, to.line + 1); } + else if (marker.className || marker.startStyle || marker.endStyle || marker.css || + marker.attributes || marker.title) + { for (var i = from.line; i <= to.line; i++) { regLineChange(cm, i, "text"); } } + if (marker.atomic) { reCheckSelection(cm.doc); } + signalLater(cm, "markerAdded", cm, marker); + } + return marker + } + + // SHARED TEXTMARKERS + + // A shared marker spans multiple linked documents. It is + // implemented as a meta-marker-object controlling multiple normal + // markers. + var SharedTextMarker = function(markers, primary) { + this.markers = markers; + this.primary = primary; + for (var i = 0; i < markers.length; ++i) + { markers[i].parent = this; } + }; + + SharedTextMarker.prototype.clear = function () { + if (this.explicitlyCleared) { return } + this.explicitlyCleared = true; + for (var i = 0; i < this.markers.length; ++i) + { this.markers[i].clear(); } + signalLater(this, "clear"); + }; + + SharedTextMarker.prototype.find = function (side, lineObj) { + return this.primary.find(side, lineObj) + }; + eventMixin(SharedTextMarker); + + function markTextShared(doc, from, to, options, type) { + options = copyObj(options); + options.shared = false; + var markers = [markText(doc, from, to, options, type)], primary = markers[0]; + var widget = options.widgetNode; + linkedDocs(doc, function (doc) { + if (widget) { options.widgetNode = widget.cloneNode(true); } + markers.push(markText(doc, clipPos(doc, from), clipPos(doc, to), options, type)); + for (var i = 0; i < doc.linked.length; ++i) + { if (doc.linked[i].isParent) { return } } + primary = lst(markers); + }); + return new SharedTextMarker(markers, primary) + } + + function findSharedMarkers(doc) { + return doc.findMarks(Pos(doc.first, 0), doc.clipPos(Pos(doc.lastLine())), function (m) { return m.parent; }) + } + + function copySharedMarkers(doc, markers) { + for (var i = 0; i < markers.length; i++) { + var marker = markers[i], pos = marker.find(); + var mFrom = doc.clipPos(pos.from), mTo = doc.clipPos(pos.to); + if (cmp(mFrom, mTo)) { + var subMark = markText(doc, mFrom, mTo, marker.primary, marker.primary.type); + marker.markers.push(subMark); + subMark.parent = marker; + } + } + } + + function detachSharedMarkers(markers) { + var loop = function ( i ) { + var marker = markers[i], linked = [marker.primary.doc]; + linkedDocs(marker.primary.doc, function (d) { return linked.push(d); }); + for (var j = 0; j < marker.markers.length; j++) { + var subMarker = marker.markers[j]; + if (indexOf(linked, subMarker.doc) == -1) { + subMarker.parent = null; + marker.markers.splice(j--, 1); + } + } + }; + + for (var i = 0; i < markers.length; i++) loop( i ); + } + + var nextDocId = 0; + var Doc = function(text, mode, firstLine, lineSep, direction) { + if (!(this instanceof Doc)) { return new Doc(text, mode, firstLine, lineSep, direction) } + if (firstLine == null) { firstLine = 0; } + + BranchChunk.call(this, [new LeafChunk([new Line("", null)])]); + this.first = firstLine; + this.scrollTop = this.scrollLeft = 0; + this.cantEdit = false; + this.cleanGeneration = 1; + this.modeFrontier = this.highlightFrontier = firstLine; + var start = Pos(firstLine, 0); + this.sel = simpleSelection(start); + this.history = new History(null); + this.id = ++nextDocId; + this.modeOption = mode; + this.lineSep = lineSep; + this.direction = (direction == "rtl") ? "rtl" : "ltr"; + this.extend = false; + + if (typeof text == "string") { text = this.splitLines(text); } + updateDoc(this, {from: start, to: start, text: text}); + setSelection(this, simpleSelection(start), sel_dontScroll); + }; + + Doc.prototype = createObj(BranchChunk.prototype, { + constructor: Doc, + // Iterate over the document. Supports two forms -- with only one + // argument, it calls that for each line in the document. With + // three, it iterates over the range given by the first two (with + // the second being non-inclusive). + iter: function(from, to, op) { + if (op) { this.iterN(from - this.first, to - from, op); } + else { this.iterN(this.first, this.first + this.size, from); } + }, + + // Non-public interface for adding and removing lines. + insert: function(at, lines) { + var height = 0; + for (var i = 0; i < lines.length; ++i) { height += lines[i].height; } + this.insertInner(at - this.first, lines, height); + }, + remove: function(at, n) { this.removeInner(at - this.first, n); }, + + // From here, the methods are part of the public interface. Most + // are also available from CodeMirror (editor) instances. + + getValue: function(lineSep) { + var lines = getLines(this, this.first, this.first + this.size); + if (lineSep === false) { return lines } + return lines.join(lineSep || this.lineSeparator()) + }, + setValue: docMethodOp(function(code) { + var top = Pos(this.first, 0), last = this.first + this.size - 1; + makeChange(this, {from: top, to: Pos(last, getLine(this, last).text.length), + text: this.splitLines(code), origin: "setValue", full: true}, true); + if (this.cm) { scrollToCoords(this.cm, 0, 0); } + setSelection(this, simpleSelection(top), sel_dontScroll); + }), + replaceRange: function(code, from, to, origin) { + from = clipPos(this, from); + to = to ? clipPos(this, to) : from; + replaceRange(this, code, from, to, origin); + }, + getRange: function(from, to, lineSep) { + var lines = getBetween(this, clipPos(this, from), clipPos(this, to)); + if (lineSep === false) { return lines } + if (lineSep === '') { return lines.join('') } + return lines.join(lineSep || this.lineSeparator()) + }, + + getLine: function(line) {var l = this.getLineHandle(line); return l && l.text}, + + getLineHandle: function(line) {if (isLine(this, line)) { return getLine(this, line) }}, + getLineNumber: function(line) {return lineNo(line)}, + + getLineHandleVisualStart: function(line) { + if (typeof line == "number") { line = getLine(this, line); } + return visualLine(line) + }, + + lineCount: function() {return this.size}, + firstLine: function() {return this.first}, + lastLine: function() {return this.first + this.size - 1}, + + clipPos: function(pos) {return clipPos(this, pos)}, + + getCursor: function(start) { + var range = this.sel.primary(), pos; + if (start == null || start == "head") { pos = range.head; } + else if (start == "anchor") { pos = range.anchor; } + else if (start == "end" || start == "to" || start === false) { pos = range.to(); } + else { pos = range.from(); } + return pos + }, + listSelections: function() { return this.sel.ranges }, + somethingSelected: function() {return this.sel.somethingSelected()}, + + setCursor: docMethodOp(function(line, ch, options) { + setSimpleSelection(this, clipPos(this, typeof line == "number" ? Pos(line, ch || 0) : line), null, options); + }), + setSelection: docMethodOp(function(anchor, head, options) { + setSimpleSelection(this, clipPos(this, anchor), clipPos(this, head || anchor), options); + }), + extendSelection: docMethodOp(function(head, other, options) { + extendSelection(this, clipPos(this, head), other && clipPos(this, other), options); + }), + extendSelections: docMethodOp(function(heads, options) { + extendSelections(this, clipPosArray(this, heads), options); + }), + extendSelectionsBy: docMethodOp(function(f, options) { + var heads = map(this.sel.ranges, f); + extendSelections(this, clipPosArray(this, heads), options); + }), + setSelections: docMethodOp(function(ranges, primary, options) { + if (!ranges.length) { return } + var out = []; + for (var i = 0; i < ranges.length; i++) + { out[i] = new Range(clipPos(this, ranges[i].anchor), + clipPos(this, ranges[i].head || ranges[i].anchor)); } + if (primary == null) { primary = Math.min(ranges.length - 1, this.sel.primIndex); } + setSelection(this, normalizeSelection(this.cm, out, primary), options); + }), + addSelection: docMethodOp(function(anchor, head, options) { + var ranges = this.sel.ranges.slice(0); + ranges.push(new Range(clipPos(this, anchor), clipPos(this, head || anchor))); + setSelection(this, normalizeSelection(this.cm, ranges, ranges.length - 1), options); + }), + + getSelection: function(lineSep) { + var ranges = this.sel.ranges, lines; + for (var i = 0; i < ranges.length; i++) { + var sel = getBetween(this, ranges[i].from(), ranges[i].to()); + lines = lines ? lines.concat(sel) : sel; + } + if (lineSep === false) { return lines } + else { return lines.join(lineSep || this.lineSeparator()) } + }, + getSelections: function(lineSep) { + var parts = [], ranges = this.sel.ranges; + for (var i = 0; i < ranges.length; i++) { + var sel = getBetween(this, ranges[i].from(), ranges[i].to()); + if (lineSep !== false) { sel = sel.join(lineSep || this.lineSeparator()); } + parts[i] = sel; + } + return parts + }, + replaceSelection: function(code, collapse, origin) { + var dup = []; + for (var i = 0; i < this.sel.ranges.length; i++) + { dup[i] = code; } + this.replaceSelections(dup, collapse, origin || "+input"); + }, + replaceSelections: docMethodOp(function(code, collapse, origin) { + var changes = [], sel = this.sel; + for (var i = 0; i < sel.ranges.length; i++) { + var range = sel.ranges[i]; + changes[i] = {from: range.from(), to: range.to(), text: this.splitLines(code[i]), origin: origin}; + } + var newSel = collapse && collapse != "end" && computeReplacedSel(this, changes, collapse); + for (var i$1 = changes.length - 1; i$1 >= 0; i$1--) + { makeChange(this, changes[i$1]); } + if (newSel) { setSelectionReplaceHistory(this, newSel); } + else if (this.cm) { ensureCursorVisible(this.cm); } + }), + undo: docMethodOp(function() {makeChangeFromHistory(this, "undo");}), + redo: docMethodOp(function() {makeChangeFromHistory(this, "redo");}), + undoSelection: docMethodOp(function() {makeChangeFromHistory(this, "undo", true);}), + redoSelection: docMethodOp(function() {makeChangeFromHistory(this, "redo", true);}), + + setExtending: function(val) {this.extend = val;}, + getExtending: function() {return this.extend}, + + historySize: function() { + var hist = this.history, done = 0, undone = 0; + for (var i = 0; i < hist.done.length; i++) { if (!hist.done[i].ranges) { ++done; } } + for (var i$1 = 0; i$1 < hist.undone.length; i$1++) { if (!hist.undone[i$1].ranges) { ++undone; } } + return {undo: done, redo: undone} + }, + clearHistory: function() { + var this$1 = this; + + this.history = new History(this.history); + linkedDocs(this, function (doc) { return doc.history = this$1.history; }, true); + }, + + markClean: function() { + this.cleanGeneration = this.changeGeneration(true); + }, + changeGeneration: function(forceSplit) { + if (forceSplit) + { this.history.lastOp = this.history.lastSelOp = this.history.lastOrigin = null; } + return this.history.generation + }, + isClean: function (gen) { + return this.history.generation == (gen || this.cleanGeneration) + }, + + getHistory: function() { + return {done: copyHistoryArray(this.history.done), + undone: copyHistoryArray(this.history.undone)} + }, + setHistory: function(histData) { + var hist = this.history = new History(this.history); + hist.done = copyHistoryArray(histData.done.slice(0), null, true); + hist.undone = copyHistoryArray(histData.undone.slice(0), null, true); + }, + + setGutterMarker: docMethodOp(function(line, gutterID, value) { + return changeLine(this, line, "gutter", function (line) { + var markers = line.gutterMarkers || (line.gutterMarkers = {}); + markers[gutterID] = value; + if (!value && isEmpty(markers)) { line.gutterMarkers = null; } + return true + }) + }), + + clearGutter: docMethodOp(function(gutterID) { + var this$1 = this; + + this.iter(function (line) { + if (line.gutterMarkers && line.gutterMarkers[gutterID]) { + changeLine(this$1, line, "gutter", function () { + line.gutterMarkers[gutterID] = null; + if (isEmpty(line.gutterMarkers)) { line.gutterMarkers = null; } + return true + }); + } + }); + }), + + lineInfo: function(line) { + var n; + if (typeof line == "number") { + if (!isLine(this, line)) { return null } + n = line; + line = getLine(this, line); + if (!line) { return null } + } else { + n = lineNo(line); + if (n == null) { return null } + } + return {line: n, handle: line, text: line.text, gutterMarkers: line.gutterMarkers, + textClass: line.textClass, bgClass: line.bgClass, wrapClass: line.wrapClass, + widgets: line.widgets} + }, + + addLineClass: docMethodOp(function(handle, where, cls) { + return changeLine(this, handle, where == "gutter" ? "gutter" : "class", function (line) { + var prop = where == "text" ? "textClass" + : where == "background" ? "bgClass" + : where == "gutter" ? "gutterClass" : "wrapClass"; + if (!line[prop]) { line[prop] = cls; } + else if (classTest(cls).test(line[prop])) { return false } + else { line[prop] += " " + cls; } + return true + }) + }), + removeLineClass: docMethodOp(function(handle, where, cls) { + return changeLine(this, handle, where == "gutter" ? "gutter" : "class", function (line) { + var prop = where == "text" ? "textClass" + : where == "background" ? "bgClass" + : where == "gutter" ? "gutterClass" : "wrapClass"; + var cur = line[prop]; + if (!cur) { return false } + else if (cls == null) { line[prop] = null; } + else { + var found = cur.match(classTest(cls)); + if (!found) { return false } + var end = found.index + found[0].length; + line[prop] = cur.slice(0, found.index) + (!found.index || end == cur.length ? "" : " ") + cur.slice(end) || null; + } + return true + }) + }), + + addLineWidget: docMethodOp(function(handle, node, options) { + return addLineWidget(this, handle, node, options) + }), + removeLineWidget: function(widget) { widget.clear(); }, + + markText: function(from, to, options) { + return markText(this, clipPos(this, from), clipPos(this, to), options, options && options.type || "range") + }, + setBookmark: function(pos, options) { + var realOpts = {replacedWith: options && (options.nodeType == null ? options.widget : options), + insertLeft: options && options.insertLeft, + clearWhenEmpty: false, shared: options && options.shared, + handleMouseEvents: options && options.handleMouseEvents}; + pos = clipPos(this, pos); + return markText(this, pos, pos, realOpts, "bookmark") + }, + findMarksAt: function(pos) { + pos = clipPos(this, pos); + var markers = [], spans = getLine(this, pos.line).markedSpans; + if (spans) { for (var i = 0; i < spans.length; ++i) { + var span = spans[i]; + if ((span.from == null || span.from <= pos.ch) && + (span.to == null || span.to >= pos.ch)) + { markers.push(span.marker.parent || span.marker); } + } } + return markers + }, + findMarks: function(from, to, filter) { + from = clipPos(this, from); to = clipPos(this, to); + var found = [], lineNo = from.line; + this.iter(from.line, to.line + 1, function (line) { + var spans = line.markedSpans; + if (spans) { for (var i = 0; i < spans.length; i++) { + var span = spans[i]; + if (!(span.to != null && lineNo == from.line && from.ch >= span.to || + span.from == null && lineNo != from.line || + span.from != null && lineNo == to.line && span.from >= to.ch) && + (!filter || filter(span.marker))) + { found.push(span.marker.parent || span.marker); } + } } + ++lineNo; + }); + return found + }, + getAllMarks: function() { + var markers = []; + this.iter(function (line) { + var sps = line.markedSpans; + if (sps) { for (var i = 0; i < sps.length; ++i) + { if (sps[i].from != null) { markers.push(sps[i].marker); } } } + }); + return markers + }, + + posFromIndex: function(off) { + var ch, lineNo = this.first, sepSize = this.lineSeparator().length; + this.iter(function (line) { + var sz = line.text.length + sepSize; + if (sz > off) { ch = off; return true } + off -= sz; + ++lineNo; + }); + return clipPos(this, Pos(lineNo, ch)) + }, + indexFromPos: function (coords) { + coords = clipPos(this, coords); + var index = coords.ch; + if (coords.line < this.first || coords.ch < 0) { return 0 } + var sepSize = this.lineSeparator().length; + this.iter(this.first, coords.line, function (line) { // iter aborts when callback returns a truthy value + index += line.text.length + sepSize; + }); + return index + }, + + copy: function(copyHistory) { + var doc = new Doc(getLines(this, this.first, this.first + this.size), + this.modeOption, this.first, this.lineSep, this.direction); + doc.scrollTop = this.scrollTop; doc.scrollLeft = this.scrollLeft; + doc.sel = this.sel; + doc.extend = false; + if (copyHistory) { + doc.history.undoDepth = this.history.undoDepth; + doc.setHistory(this.getHistory()); + } + return doc + }, + + linkedDoc: function(options) { + if (!options) { options = {}; } + var from = this.first, to = this.first + this.size; + if (options.from != null && options.from > from) { from = options.from; } + if (options.to != null && options.to < to) { to = options.to; } + var copy = new Doc(getLines(this, from, to), options.mode || this.modeOption, from, this.lineSep, this.direction); + if (options.sharedHist) { copy.history = this.history + ; }(this.linked || (this.linked = [])).push({doc: copy, sharedHist: options.sharedHist}); + copy.linked = [{doc: this, isParent: true, sharedHist: options.sharedHist}]; + copySharedMarkers(copy, findSharedMarkers(this)); + return copy + }, + unlinkDoc: function(other) { + if (other instanceof CodeMirror) { other = other.doc; } + if (this.linked) { for (var i = 0; i < this.linked.length; ++i) { + var link = this.linked[i]; + if (link.doc != other) { continue } + this.linked.splice(i, 1); + other.unlinkDoc(this); + detachSharedMarkers(findSharedMarkers(this)); + break + } } + // If the histories were shared, split them again + if (other.history == this.history) { + var splitIds = [other.id]; + linkedDocs(other, function (doc) { return splitIds.push(doc.id); }, true); + other.history = new History(null); + other.history.done = copyHistoryArray(this.history.done, splitIds); + other.history.undone = copyHistoryArray(this.history.undone, splitIds); + } + }, + iterLinkedDocs: function(f) {linkedDocs(this, f);}, + + getMode: function() {return this.mode}, + getEditor: function() {return this.cm}, + + splitLines: function(str) { + if (this.lineSep) { return str.split(this.lineSep) } + return splitLinesAuto(str) + }, + lineSeparator: function() { return this.lineSep || "\n" }, + + setDirection: docMethodOp(function (dir) { + if (dir != "rtl") { dir = "ltr"; } + if (dir == this.direction) { return } + this.direction = dir; + this.iter(function (line) { return line.order = null; }); + if (this.cm) { directionChanged(this.cm); } + }) + }); + + // Public alias. + Doc.prototype.eachLine = Doc.prototype.iter; + + // Kludge to work around strange IE behavior where it'll sometimes + // re-fire a series of drag-related events right after the drop (#1551) + var lastDrop = 0; + + function onDrop(e) { + var cm = this; + clearDragCursor(cm); + if (signalDOMEvent(cm, e) || eventInWidget(cm.display, e)) + { return } + e_preventDefault(e); + if (ie) { lastDrop = +new Date; } + var pos = posFromMouse(cm, e, true), files = e.dataTransfer.files; + if (!pos || cm.isReadOnly()) { return } + // Might be a file drop, in which case we simply extract the text + // and insert it. + if (files && files.length && window.FileReader && window.File) { + var n = files.length, text = Array(n), read = 0; + var markAsReadAndPasteIfAllFilesAreRead = function () { + if (++read == n) { + operation(cm, function () { + pos = clipPos(cm.doc, pos); + var change = {from: pos, to: pos, + text: cm.doc.splitLines( + text.filter(function (t) { return t != null; }).join(cm.doc.lineSeparator())), + origin: "paste"}; + makeChange(cm.doc, change); + setSelectionReplaceHistory(cm.doc, simpleSelection(clipPos(cm.doc, pos), clipPos(cm.doc, changeEnd(change)))); + })(); + } + }; + var readTextFromFile = function (file, i) { + if (cm.options.allowDropFileTypes && + indexOf(cm.options.allowDropFileTypes, file.type) == -1) { + markAsReadAndPasteIfAllFilesAreRead(); + return + } + var reader = new FileReader; + reader.onerror = function () { return markAsReadAndPasteIfAllFilesAreRead(); }; + reader.onload = function () { + var content = reader.result; + if (/[\x00-\x08\x0e-\x1f]{2}/.test(content)) { + markAsReadAndPasteIfAllFilesAreRead(); + return + } + text[i] = content; + markAsReadAndPasteIfAllFilesAreRead(); + }; + reader.readAsText(file); + }; + for (var i = 0; i < files.length; i++) { readTextFromFile(files[i], i); } + } else { // Normal drop + // Don't do a replace if the drop happened inside of the selected text. + if (cm.state.draggingText && cm.doc.sel.contains(pos) > -1) { + cm.state.draggingText(e); + // Ensure the editor is re-focused + setTimeout(function () { return cm.display.input.focus(); }, 20); + return + } + try { + var text$1 = e.dataTransfer.getData("Text"); + if (text$1) { + var selected; + if (cm.state.draggingText && !cm.state.draggingText.copy) + { selected = cm.listSelections(); } + setSelectionNoUndo(cm.doc, simpleSelection(pos, pos)); + if (selected) { for (var i$1 = 0; i$1 < selected.length; ++i$1) + { replaceRange(cm.doc, "", selected[i$1].anchor, selected[i$1].head, "drag"); } } + cm.replaceSelection(text$1, "around", "paste"); + cm.display.input.focus(); + } + } + catch(e$1){} + } + } + + function onDragStart(cm, e) { + if (ie && (!cm.state.draggingText || +new Date - lastDrop < 100)) { e_stop(e); return } + if (signalDOMEvent(cm, e) || eventInWidget(cm.display, e)) { return } + + e.dataTransfer.setData("Text", cm.getSelection()); + e.dataTransfer.effectAllowed = "copyMove"; + + // Use dummy image instead of default browsers image. + // Recent Safari (~6.0.2) have a tendency to segfault when this happens, so we don't do it there. + if (e.dataTransfer.setDragImage && !safari) { + var img = elt("img", null, null, "position: fixed; left: 0; top: 0;"); + img.src = "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; + if (presto) { + img.width = img.height = 1; + cm.display.wrapper.appendChild(img); + // Force a relayout, or Opera won't use our image for some obscure reason + img._top = img.offsetTop; + } + e.dataTransfer.setDragImage(img, 0, 0); + if (presto) { img.parentNode.removeChild(img); } + } + } + + function onDragOver(cm, e) { + var pos = posFromMouse(cm, e); + if (!pos) { return } + var frag = document.createDocumentFragment(); + drawSelectionCursor(cm, pos, frag); + if (!cm.display.dragCursor) { + cm.display.dragCursor = elt("div", null, "CodeMirror-cursors CodeMirror-dragcursors"); + cm.display.lineSpace.insertBefore(cm.display.dragCursor, cm.display.cursorDiv); + } + removeChildrenAndAdd(cm.display.dragCursor, frag); + } + + function clearDragCursor(cm) { + if (cm.display.dragCursor) { + cm.display.lineSpace.removeChild(cm.display.dragCursor); + cm.display.dragCursor = null; + } + } + + // These must be handled carefully, because naively registering a + // handler for each editor will cause the editors to never be + // garbage collected. + + function forEachCodeMirror(f) { + if (!document.getElementsByClassName) { return } + var byClass = document.getElementsByClassName("CodeMirror"), editors = []; + for (var i = 0; i < byClass.length; i++) { + var cm = byClass[i].CodeMirror; + if (cm) { editors.push(cm); } + } + if (editors.length) { editors[0].operation(function () { + for (var i = 0; i < editors.length; i++) { f(editors[i]); } + }); } + } + + var globalsRegistered = false; + function ensureGlobalHandlers() { + if (globalsRegistered) { return } + registerGlobalHandlers(); + globalsRegistered = true; + } + function registerGlobalHandlers() { + // When the window resizes, we need to refresh active editors. + var resizeTimer; + on(window, "resize", function () { + if (resizeTimer == null) { resizeTimer = setTimeout(function () { + resizeTimer = null; + forEachCodeMirror(onResize); + }, 100); } + }); + // When the window loses focus, we want to show the editor as blurred + on(window, "blur", function () { return forEachCodeMirror(onBlur); }); + } + // Called when the window resizes + function onResize(cm) { + var d = cm.display; + // Might be a text scaling operation, clear size caches. + d.cachedCharWidth = d.cachedTextHeight = d.cachedPaddingH = null; + d.scrollbarsClipped = false; + cm.setSize(); + } + + var keyNames = { + 3: "Pause", 8: "Backspace", 9: "Tab", 13: "Enter", 16: "Shift", 17: "Ctrl", 18: "Alt", + 19: "Pause", 20: "CapsLock", 27: "Esc", 32: "Space", 33: "PageUp", 34: "PageDown", 35: "End", + 36: "Home", 37: "Left", 38: "Up", 39: "Right", 40: "Down", 44: "PrintScrn", 45: "Insert", + 46: "Delete", 59: ";", 61: "=", 91: "Mod", 92: "Mod", 93: "Mod", + 106: "*", 107: "=", 109: "-", 110: ".", 111: "/", 145: "ScrollLock", + 173: "-", 186: ";", 187: "=", 188: ",", 189: "-", 190: ".", 191: "/", 192: "`", 219: "[", 220: "\\", + 221: "]", 222: "'", 224: "Mod", 63232: "Up", 63233: "Down", 63234: "Left", 63235: "Right", 63272: "Delete", + 63273: "Home", 63275: "End", 63276: "PageUp", 63277: "PageDown", 63302: "Insert" + }; + + // Number keys + for (var i = 0; i < 10; i++) { keyNames[i + 48] = keyNames[i + 96] = String(i); } + // Alphabetic keys + for (var i$1 = 65; i$1 <= 90; i$1++) { keyNames[i$1] = String.fromCharCode(i$1); } + // Function keys + for (var i$2 = 1; i$2 <= 12; i$2++) { keyNames[i$2 + 111] = keyNames[i$2 + 63235] = "F" + i$2; } + + var keyMap = {}; + + keyMap.basic = { + "Left": "goCharLeft", "Right": "goCharRight", "Up": "goLineUp", "Down": "goLineDown", + "End": "goLineEnd", "Home": "goLineStartSmart", "PageUp": "goPageUp", "PageDown": "goPageDown", + "Delete": "delCharAfter", "Backspace": "delCharBefore", "Shift-Backspace": "delCharBefore", + "Tab": "defaultTab", "Shift-Tab": "indentAuto", + "Enter": "newlineAndIndent", "Insert": "toggleOverwrite", + "Esc": "singleSelection" + }; + // Note that the save and find-related commands aren't defined by + // default. User code or addons can define them. Unknown commands + // are simply ignored. + keyMap.pcDefault = { + "Ctrl-A": "selectAll", "Ctrl-D": "deleteLine", "Ctrl-Z": "undo", "Shift-Ctrl-Z": "redo", "Ctrl-Y": "redo", + "Ctrl-Home": "goDocStart", "Ctrl-End": "goDocEnd", "Ctrl-Up": "goLineUp", "Ctrl-Down": "goLineDown", + "Ctrl-Left": "goGroupLeft", "Ctrl-Right": "goGroupRight", "Alt-Left": "goLineStart", "Alt-Right": "goLineEnd", + "Ctrl-Backspace": "delGroupBefore", "Ctrl-Delete": "delGroupAfter", "Ctrl-S": "save", "Ctrl-F": "find", + "Ctrl-G": "findNext", "Shift-Ctrl-G": "findPrev", "Shift-Ctrl-F": "replace", "Shift-Ctrl-R": "replaceAll", + "Ctrl-[": "indentLess", "Ctrl-]": "indentMore", + "Ctrl-U": "undoSelection", "Shift-Ctrl-U": "redoSelection", "Alt-U": "redoSelection", + "fallthrough": "basic" + }; + // Very basic readline/emacs-style bindings, which are standard on Mac. + keyMap.emacsy = { + "Ctrl-F": "goCharRight", "Ctrl-B": "goCharLeft", "Ctrl-P": "goLineUp", "Ctrl-N": "goLineDown", + "Ctrl-A": "goLineStart", "Ctrl-E": "goLineEnd", "Ctrl-V": "goPageDown", "Shift-Ctrl-V": "goPageUp", + "Ctrl-D": "delCharAfter", "Ctrl-H": "delCharBefore", "Alt-Backspace": "delWordBefore", "Ctrl-K": "killLine", + "Ctrl-T": "transposeChars", "Ctrl-O": "openLine" + }; + keyMap.macDefault = { + "Cmd-A": "selectAll", "Cmd-D": "deleteLine", "Cmd-Z": "undo", "Shift-Cmd-Z": "redo", "Cmd-Y": "redo", + "Cmd-Home": "goDocStart", "Cmd-Up": "goDocStart", "Cmd-End": "goDocEnd", "Cmd-Down": "goDocEnd", "Alt-Left": "goGroupLeft", + "Alt-Right": "goGroupRight", "Cmd-Left": "goLineLeft", "Cmd-Right": "goLineRight", "Alt-Backspace": "delGroupBefore", + "Ctrl-Alt-Backspace": "delGroupAfter", "Alt-Delete": "delGroupAfter", "Cmd-S": "save", "Cmd-F": "find", + "Cmd-G": "findNext", "Shift-Cmd-G": "findPrev", "Cmd-Alt-F": "replace", "Shift-Cmd-Alt-F": "replaceAll", + "Cmd-[": "indentLess", "Cmd-]": "indentMore", "Cmd-Backspace": "delWrappedLineLeft", "Cmd-Delete": "delWrappedLineRight", + "Cmd-U": "undoSelection", "Shift-Cmd-U": "redoSelection", "Ctrl-Up": "goDocStart", "Ctrl-Down": "goDocEnd", + "fallthrough": ["basic", "emacsy"] + }; + keyMap["default"] = mac ? keyMap.macDefault : keyMap.pcDefault; + + // KEYMAP DISPATCH + + function normalizeKeyName(name) { + var parts = name.split(/-(?!$)/); + name = parts[parts.length - 1]; + var alt, ctrl, shift, cmd; + for (var i = 0; i < parts.length - 1; i++) { + var mod = parts[i]; + if (/^(cmd|meta|m)$/i.test(mod)) { cmd = true; } + else if (/^a(lt)?$/i.test(mod)) { alt = true; } + else if (/^(c|ctrl|control)$/i.test(mod)) { ctrl = true; } + else if (/^s(hift)?$/i.test(mod)) { shift = true; } + else { throw new Error("Unrecognized modifier name: " + mod) } + } + if (alt) { name = "Alt-" + name; } + if (ctrl) { name = "Ctrl-" + name; } + if (cmd) { name = "Cmd-" + name; } + if (shift) { name = "Shift-" + name; } + return name + } + + // This is a kludge to keep keymaps mostly working as raw objects + // (backwards compatibility) while at the same time support features + // like normalization and multi-stroke key bindings. It compiles a + // new normalized keymap, and then updates the old object to reflect + // this. + function normalizeKeyMap(keymap) { + var copy = {}; + for (var keyname in keymap) { if (keymap.hasOwnProperty(keyname)) { + var value = keymap[keyname]; + if (/^(name|fallthrough|(de|at)tach)$/.test(keyname)) { continue } + if (value == "...") { delete keymap[keyname]; continue } + + var keys = map(keyname.split(" "), normalizeKeyName); + for (var i = 0; i < keys.length; i++) { + var val = (void 0), name = (void 0); + if (i == keys.length - 1) { + name = keys.join(" "); + val = value; + } else { + name = keys.slice(0, i + 1).join(" "); + val = "..."; + } + var prev = copy[name]; + if (!prev) { copy[name] = val; } + else if (prev != val) { throw new Error("Inconsistent bindings for " + name) } + } + delete keymap[keyname]; + } } + for (var prop in copy) { keymap[prop] = copy[prop]; } + return keymap + } + + function lookupKey(key, map, handle, context) { + map = getKeyMap(map); + var found = map.call ? map.call(key, context) : map[key]; + if (found === false) { return "nothing" } + if (found === "...") { return "multi" } + if (found != null && handle(found)) { return "handled" } + + if (map.fallthrough) { + if (Object.prototype.toString.call(map.fallthrough) != "[object Array]") + { return lookupKey(key, map.fallthrough, handle, context) } + for (var i = 0; i < map.fallthrough.length; i++) { + var result = lookupKey(key, map.fallthrough[i], handle, context); + if (result) { return result } + } + } + } + + // Modifier key presses don't count as 'real' key presses for the + // purpose of keymap fallthrough. + function isModifierKey(value) { + var name = typeof value == "string" ? value : keyNames[value.keyCode]; + return name == "Ctrl" || name == "Alt" || name == "Shift" || name == "Mod" + } + + function addModifierNames(name, event, noShift) { + var base = name; + if (event.altKey && base != "Alt") { name = "Alt-" + name; } + if ((flipCtrlCmd ? event.metaKey : event.ctrlKey) && base != "Ctrl") { name = "Ctrl-" + name; } + if ((flipCtrlCmd ? event.ctrlKey : event.metaKey) && base != "Mod") { name = "Cmd-" + name; } + if (!noShift && event.shiftKey && base != "Shift") { name = "Shift-" + name; } + return name + } + + // Look up the name of a key as indicated by an event object. + function keyName(event, noShift) { + if (presto && event.keyCode == 34 && event["char"]) { return false } + var name = keyNames[event.keyCode]; + if (name == null || event.altGraphKey) { return false } + // Ctrl-ScrollLock has keyCode 3, same as Ctrl-Pause, + // so we'll use event.code when available (Chrome 48+, FF 38+, Safari 10.1+) + if (event.keyCode == 3 && event.code) { name = event.code; } + return addModifierNames(name, event, noShift) + } + + function getKeyMap(val) { + return typeof val == "string" ? keyMap[val] : val + } + + // Helper for deleting text near the selection(s), used to implement + // backspace, delete, and similar functionality. + function deleteNearSelection(cm, compute) { + var ranges = cm.doc.sel.ranges, kill = []; + // Build up a set of ranges to kill first, merging overlapping + // ranges. + for (var i = 0; i < ranges.length; i++) { + var toKill = compute(ranges[i]); + while (kill.length && cmp(toKill.from, lst(kill).to) <= 0) { + var replaced = kill.pop(); + if (cmp(replaced.from, toKill.from) < 0) { + toKill.from = replaced.from; + break + } + } + kill.push(toKill); + } + // Next, remove those actual ranges. + runInOp(cm, function () { + for (var i = kill.length - 1; i >= 0; i--) + { replaceRange(cm.doc, "", kill[i].from, kill[i].to, "+delete"); } + ensureCursorVisible(cm); + }); + } + + function moveCharLogically(line, ch, dir) { + var target = skipExtendingChars(line.text, ch + dir, dir); + return target < 0 || target > line.text.length ? null : target + } + + function moveLogically(line, start, dir) { + var ch = moveCharLogically(line, start.ch, dir); + return ch == null ? null : new Pos(start.line, ch, dir < 0 ? "after" : "before") + } + + function endOfLine(visually, cm, lineObj, lineNo, dir) { + if (visually) { + if (cm.doc.direction == "rtl") { dir = -dir; } + var order = getOrder(lineObj, cm.doc.direction); + if (order) { + var part = dir < 0 ? lst(order) : order[0]; + var moveInStorageOrder = (dir < 0) == (part.level == 1); + var sticky = moveInStorageOrder ? "after" : "before"; + var ch; + // With a wrapped rtl chunk (possibly spanning multiple bidi parts), + // it could be that the last bidi part is not on the last visual line, + // since visual lines contain content order-consecutive chunks. + // Thus, in rtl, we are looking for the first (content-order) character + // in the rtl chunk that is on the last line (that is, the same line + // as the last (content-order) character). + if (part.level > 0 || cm.doc.direction == "rtl") { + var prep = prepareMeasureForLine(cm, lineObj); + ch = dir < 0 ? lineObj.text.length - 1 : 0; + var targetTop = measureCharPrepared(cm, prep, ch).top; + ch = findFirst(function (ch) { return measureCharPrepared(cm, prep, ch).top == targetTop; }, (dir < 0) == (part.level == 1) ? part.from : part.to - 1, ch); + if (sticky == "before") { ch = moveCharLogically(lineObj, ch, 1); } + } else { ch = dir < 0 ? part.to : part.from; } + return new Pos(lineNo, ch, sticky) + } + } + return new Pos(lineNo, dir < 0 ? lineObj.text.length : 0, dir < 0 ? "before" : "after") + } + + function moveVisually(cm, line, start, dir) { + var bidi = getOrder(line, cm.doc.direction); + if (!bidi) { return moveLogically(line, start, dir) } + if (start.ch >= line.text.length) { + start.ch = line.text.length; + start.sticky = "before"; + } else if (start.ch <= 0) { + start.ch = 0; + start.sticky = "after"; + } + var partPos = getBidiPartAt(bidi, start.ch, start.sticky), part = bidi[partPos]; + if (cm.doc.direction == "ltr" && part.level % 2 == 0 && (dir > 0 ? part.to > start.ch : part.from < start.ch)) { + // Case 1: We move within an ltr part in an ltr editor. Even with wrapped lines, + // nothing interesting happens. + return moveLogically(line, start, dir) + } + + var mv = function (pos, dir) { return moveCharLogically(line, pos instanceof Pos ? pos.ch : pos, dir); }; + var prep; + var getWrappedLineExtent = function (ch) { + if (!cm.options.lineWrapping) { return {begin: 0, end: line.text.length} } + prep = prep || prepareMeasureForLine(cm, line); + return wrappedLineExtentChar(cm, line, prep, ch) + }; + var wrappedLineExtent = getWrappedLineExtent(start.sticky == "before" ? mv(start, -1) : start.ch); + + if (cm.doc.direction == "rtl" || part.level == 1) { + var moveInStorageOrder = (part.level == 1) == (dir < 0); + var ch = mv(start, moveInStorageOrder ? 1 : -1); + if (ch != null && (!moveInStorageOrder ? ch >= part.from && ch >= wrappedLineExtent.begin : ch <= part.to && ch <= wrappedLineExtent.end)) { + // Case 2: We move within an rtl part or in an rtl editor on the same visual line + var sticky = moveInStorageOrder ? "before" : "after"; + return new Pos(start.line, ch, sticky) + } + } + + // Case 3: Could not move within this bidi part in this visual line, so leave + // the current bidi part + + var searchInVisualLine = function (partPos, dir, wrappedLineExtent) { + var getRes = function (ch, moveInStorageOrder) { return moveInStorageOrder + ? new Pos(start.line, mv(ch, 1), "before") + : new Pos(start.line, ch, "after"); }; + + for (; partPos >= 0 && partPos < bidi.length; partPos += dir) { + var part = bidi[partPos]; + var moveInStorageOrder = (dir > 0) == (part.level != 1); + var ch = moveInStorageOrder ? wrappedLineExtent.begin : mv(wrappedLineExtent.end, -1); + if (part.from <= ch && ch < part.to) { return getRes(ch, moveInStorageOrder) } + ch = moveInStorageOrder ? part.from : mv(part.to, -1); + if (wrappedLineExtent.begin <= ch && ch < wrappedLineExtent.end) { return getRes(ch, moveInStorageOrder) } + } + }; + + // Case 3a: Look for other bidi parts on the same visual line + var res = searchInVisualLine(partPos + dir, dir, wrappedLineExtent); + if (res) { return res } + + // Case 3b: Look for other bidi parts on the next visual line + var nextCh = dir > 0 ? wrappedLineExtent.end : mv(wrappedLineExtent.begin, -1); + if (nextCh != null && !(dir > 0 && nextCh == line.text.length)) { + res = searchInVisualLine(dir > 0 ? 0 : bidi.length - 1, dir, getWrappedLineExtent(nextCh)); + if (res) { return res } + } + + // Case 4: Nowhere to move + return null + } + + // Commands are parameter-less actions that can be performed on an + // editor, mostly used for keybindings. + var commands = { + selectAll: selectAll, + singleSelection: function (cm) { return cm.setSelection(cm.getCursor("anchor"), cm.getCursor("head"), sel_dontScroll); }, + killLine: function (cm) { return deleteNearSelection(cm, function (range) { + if (range.empty()) { + var len = getLine(cm.doc, range.head.line).text.length; + if (range.head.ch == len && range.head.line < cm.lastLine()) + { return {from: range.head, to: Pos(range.head.line + 1, 0)} } + else + { return {from: range.head, to: Pos(range.head.line, len)} } + } else { + return {from: range.from(), to: range.to()} + } + }); }, + deleteLine: function (cm) { return deleteNearSelection(cm, function (range) { return ({ + from: Pos(range.from().line, 0), + to: clipPos(cm.doc, Pos(range.to().line + 1, 0)) + }); }); }, + delLineLeft: function (cm) { return deleteNearSelection(cm, function (range) { return ({ + from: Pos(range.from().line, 0), to: range.from() + }); }); }, + delWrappedLineLeft: function (cm) { return deleteNearSelection(cm, function (range) { + var top = cm.charCoords(range.head, "div").top + 5; + var leftPos = cm.coordsChar({left: 0, top: top}, "div"); + return {from: leftPos, to: range.from()} + }); }, + delWrappedLineRight: function (cm) { return deleteNearSelection(cm, function (range) { + var top = cm.charCoords(range.head, "div").top + 5; + var rightPos = cm.coordsChar({left: cm.display.lineDiv.offsetWidth + 100, top: top}, "div"); + return {from: range.from(), to: rightPos } + }); }, + undo: function (cm) { return cm.undo(); }, + redo: function (cm) { return cm.redo(); }, + undoSelection: function (cm) { return cm.undoSelection(); }, + redoSelection: function (cm) { return cm.redoSelection(); }, + goDocStart: function (cm) { return cm.extendSelection(Pos(cm.firstLine(), 0)); }, + goDocEnd: function (cm) { return cm.extendSelection(Pos(cm.lastLine())); }, + goLineStart: function (cm) { return cm.extendSelectionsBy(function (range) { return lineStart(cm, range.head.line); }, + {origin: "+move", bias: 1} + ); }, + goLineStartSmart: function (cm) { return cm.extendSelectionsBy(function (range) { return lineStartSmart(cm, range.head); }, + {origin: "+move", bias: 1} + ); }, + goLineEnd: function (cm) { return cm.extendSelectionsBy(function (range) { return lineEnd(cm, range.head.line); }, + {origin: "+move", bias: -1} + ); }, + goLineRight: function (cm) { return cm.extendSelectionsBy(function (range) { + var top = cm.cursorCoords(range.head, "div").top + 5; + return cm.coordsChar({left: cm.display.lineDiv.offsetWidth + 100, top: top}, "div") + }, sel_move); }, + goLineLeft: function (cm) { return cm.extendSelectionsBy(function (range) { + var top = cm.cursorCoords(range.head, "div").top + 5; + return cm.coordsChar({left: 0, top: top}, "div") + }, sel_move); }, + goLineLeftSmart: function (cm) { return cm.extendSelectionsBy(function (range) { + var top = cm.cursorCoords(range.head, "div").top + 5; + var pos = cm.coordsChar({left: 0, top: top}, "div"); + if (pos.ch < cm.getLine(pos.line).search(/\S/)) { return lineStartSmart(cm, range.head) } + return pos + }, sel_move); }, + goLineUp: function (cm) { return cm.moveV(-1, "line"); }, + goLineDown: function (cm) { return cm.moveV(1, "line"); }, + goPageUp: function (cm) { return cm.moveV(-1, "page"); }, + goPageDown: function (cm) { return cm.moveV(1, "page"); }, + goCharLeft: function (cm) { return cm.moveH(-1, "char"); }, + goCharRight: function (cm) { return cm.moveH(1, "char"); }, + goColumnLeft: function (cm) { return cm.moveH(-1, "column"); }, + goColumnRight: function (cm) { return cm.moveH(1, "column"); }, + goWordLeft: function (cm) { return cm.moveH(-1, "word"); }, + goGroupRight: function (cm) { return cm.moveH(1, "group"); }, + goGroupLeft: function (cm) { return cm.moveH(-1, "group"); }, + goWordRight: function (cm) { return cm.moveH(1, "word"); }, + delCharBefore: function (cm) { return cm.deleteH(-1, "codepoint"); }, + delCharAfter: function (cm) { return cm.deleteH(1, "char"); }, + delWordBefore: function (cm) { return cm.deleteH(-1, "word"); }, + delWordAfter: function (cm) { return cm.deleteH(1, "word"); }, + delGroupBefore: function (cm) { return cm.deleteH(-1, "group"); }, + delGroupAfter: function (cm) { return cm.deleteH(1, "group"); }, + indentAuto: function (cm) { return cm.indentSelection("smart"); }, + indentMore: function (cm) { return cm.indentSelection("add"); }, + indentLess: function (cm) { return cm.indentSelection("subtract"); }, + insertTab: function (cm) { return cm.replaceSelection("\t"); }, + insertSoftTab: function (cm) { + var spaces = [], ranges = cm.listSelections(), tabSize = cm.options.tabSize; + for (var i = 0; i < ranges.length; i++) { + var pos = ranges[i].from(); + var col = countColumn(cm.getLine(pos.line), pos.ch, tabSize); + spaces.push(spaceStr(tabSize - col % tabSize)); + } + cm.replaceSelections(spaces); + }, + defaultTab: function (cm) { + if (cm.somethingSelected()) { cm.indentSelection("add"); } + else { cm.execCommand("insertTab"); } + }, + // Swap the two chars left and right of each selection's head. + // Move cursor behind the two swapped characters afterwards. + // + // Doesn't consider line feeds a character. + // Doesn't scan more than one line above to find a character. + // Doesn't do anything on an empty line. + // Doesn't do anything with non-empty selections. + transposeChars: function (cm) { return runInOp(cm, function () { + var ranges = cm.listSelections(), newSel = []; + for (var i = 0; i < ranges.length; i++) { + if (!ranges[i].empty()) { continue } + var cur = ranges[i].head, line = getLine(cm.doc, cur.line).text; + if (line) { + if (cur.ch == line.length) { cur = new Pos(cur.line, cur.ch - 1); } + if (cur.ch > 0) { + cur = new Pos(cur.line, cur.ch + 1); + cm.replaceRange(line.charAt(cur.ch - 1) + line.charAt(cur.ch - 2), + Pos(cur.line, cur.ch - 2), cur, "+transpose"); + } else if (cur.line > cm.doc.first) { + var prev = getLine(cm.doc, cur.line - 1).text; + if (prev) { + cur = new Pos(cur.line, 1); + cm.replaceRange(line.charAt(0) + cm.doc.lineSeparator() + + prev.charAt(prev.length - 1), + Pos(cur.line - 1, prev.length - 1), cur, "+transpose"); + } + } + } + newSel.push(new Range(cur, cur)); + } + cm.setSelections(newSel); + }); }, + newlineAndIndent: function (cm) { return runInOp(cm, function () { + var sels = cm.listSelections(); + for (var i = sels.length - 1; i >= 0; i--) + { cm.replaceRange(cm.doc.lineSeparator(), sels[i].anchor, sels[i].head, "+input"); } + sels = cm.listSelections(); + for (var i$1 = 0; i$1 < sels.length; i$1++) + { cm.indentLine(sels[i$1].from().line, null, true); } + ensureCursorVisible(cm); + }); }, + openLine: function (cm) { return cm.replaceSelection("\n", "start"); }, + toggleOverwrite: function (cm) { return cm.toggleOverwrite(); } + }; + + + function lineStart(cm, lineN) { + var line = getLine(cm.doc, lineN); + var visual = visualLine(line); + if (visual != line) { lineN = lineNo(visual); } + return endOfLine(true, cm, visual, lineN, 1) + } + function lineEnd(cm, lineN) { + var line = getLine(cm.doc, lineN); + var visual = visualLineEnd(line); + if (visual != line) { lineN = lineNo(visual); } + return endOfLine(true, cm, line, lineN, -1) + } + function lineStartSmart(cm, pos) { + var start = lineStart(cm, pos.line); + var line = getLine(cm.doc, start.line); + var order = getOrder(line, cm.doc.direction); + if (!order || order[0].level == 0) { + var firstNonWS = Math.max(start.ch, line.text.search(/\S/)); + var inWS = pos.line == start.line && pos.ch <= firstNonWS && pos.ch; + return Pos(start.line, inWS ? 0 : firstNonWS, start.sticky) + } + return start + } + + // Run a handler that was bound to a key. + function doHandleBinding(cm, bound, dropShift) { + if (typeof bound == "string") { + bound = commands[bound]; + if (!bound) { return false } + } + // Ensure previous input has been read, so that the handler sees a + // consistent view of the document + cm.display.input.ensurePolled(); + var prevShift = cm.display.shift, done = false; + try { + if (cm.isReadOnly()) { cm.state.suppressEdits = true; } + if (dropShift) { cm.display.shift = false; } + done = bound(cm) != Pass; + } finally { + cm.display.shift = prevShift; + cm.state.suppressEdits = false; + } + return done + } + + function lookupKeyForEditor(cm, name, handle) { + for (var i = 0; i < cm.state.keyMaps.length; i++) { + var result = lookupKey(name, cm.state.keyMaps[i], handle, cm); + if (result) { return result } + } + return (cm.options.extraKeys && lookupKey(name, cm.options.extraKeys, handle, cm)) + || lookupKey(name, cm.options.keyMap, handle, cm) + } + + // Note that, despite the name, this function is also used to check + // for bound mouse clicks. + + var stopSeq = new Delayed; + + function dispatchKey(cm, name, e, handle) { + var seq = cm.state.keySeq; + if (seq) { + if (isModifierKey(name)) { return "handled" } + if (/\'$/.test(name)) + { cm.state.keySeq = null; } + else + { stopSeq.set(50, function () { + if (cm.state.keySeq == seq) { + cm.state.keySeq = null; + cm.display.input.reset(); + } + }); } + if (dispatchKeyInner(cm, seq + " " + name, e, handle)) { return true } + } + return dispatchKeyInner(cm, name, e, handle) + } + + function dispatchKeyInner(cm, name, e, handle) { + var result = lookupKeyForEditor(cm, name, handle); + + if (result == "multi") + { cm.state.keySeq = name; } + if (result == "handled") + { signalLater(cm, "keyHandled", cm, name, e); } + + if (result == "handled" || result == "multi") { + e_preventDefault(e); + restartBlink(cm); + } + + return !!result + } + + // Handle a key from the keydown event. + function handleKeyBinding(cm, e) { + var name = keyName(e, true); + if (!name) { return false } + + if (e.shiftKey && !cm.state.keySeq) { + // First try to resolve full name (including 'Shift-'). Failing + // that, see if there is a cursor-motion command (starting with + // 'go') bound to the keyname without 'Shift-'. + return dispatchKey(cm, "Shift-" + name, e, function (b) { return doHandleBinding(cm, b, true); }) + || dispatchKey(cm, name, e, function (b) { + if (typeof b == "string" ? /^go[A-Z]/.test(b) : b.motion) + { return doHandleBinding(cm, b) } + }) + } else { + return dispatchKey(cm, name, e, function (b) { return doHandleBinding(cm, b); }) + } + } + + // Handle a key from the keypress event + function handleCharBinding(cm, e, ch) { + return dispatchKey(cm, "'" + ch + "'", e, function (b) { return doHandleBinding(cm, b, true); }) + } + + var lastStoppedKey = null; + function onKeyDown(e) { + var cm = this; + if (e.target && e.target != cm.display.input.getField()) { return } + cm.curOp.focus = activeElt(root(cm)); + if (signalDOMEvent(cm, e)) { return } + // IE does strange things with escape. + if (ie && ie_version < 11 && e.keyCode == 27) { e.returnValue = false; } + var code = e.keyCode; + cm.display.shift = code == 16 || e.shiftKey; + var handled = handleKeyBinding(cm, e); + if (presto) { + lastStoppedKey = handled ? code : null; + // Opera has no cut event... we try to at least catch the key combo + if (!handled && code == 88 && !hasCopyEvent && (mac ? e.metaKey : e.ctrlKey)) + { cm.replaceSelection("", null, "cut"); } + } + if (gecko && !mac && !handled && code == 46 && e.shiftKey && !e.ctrlKey && document.execCommand) + { document.execCommand("cut"); } + + // Turn mouse into crosshair when Alt is held on Mac. + if (code == 18 && !/\bCodeMirror-crosshair\b/.test(cm.display.lineDiv.className)) + { showCrossHair(cm); } + } + + function showCrossHair(cm) { + var lineDiv = cm.display.lineDiv; + addClass(lineDiv, "CodeMirror-crosshair"); + + function up(e) { + if (e.keyCode == 18 || !e.altKey) { + rmClass(lineDiv, "CodeMirror-crosshair"); + off(document, "keyup", up); + off(document, "mouseover", up); + } + } + on(document, "keyup", up); + on(document, "mouseover", up); + } + + function onKeyUp(e) { + if (e.keyCode == 16) { this.doc.sel.shift = false; } + signalDOMEvent(this, e); + } + + function onKeyPress(e) { + var cm = this; + if (e.target && e.target != cm.display.input.getField()) { return } + if (eventInWidget(cm.display, e) || signalDOMEvent(cm, e) || e.ctrlKey && !e.altKey || mac && e.metaKey) { return } + var keyCode = e.keyCode, charCode = e.charCode; + if (presto && keyCode == lastStoppedKey) {lastStoppedKey = null; e_preventDefault(e); return} + if ((presto && (!e.which || e.which < 10)) && handleKeyBinding(cm, e)) { return } + var ch = String.fromCharCode(charCode == null ? keyCode : charCode); + // Some browsers fire keypress events for backspace + if (ch == "\x08") { return } + if (handleCharBinding(cm, e, ch)) { return } + cm.display.input.onKeyPress(e); + } + + var DOUBLECLICK_DELAY = 400; + + var PastClick = function(time, pos, button) { + this.time = time; + this.pos = pos; + this.button = button; + }; + + PastClick.prototype.compare = function (time, pos, button) { + return this.time + DOUBLECLICK_DELAY > time && + cmp(pos, this.pos) == 0 && button == this.button + }; + + var lastClick, lastDoubleClick; + function clickRepeat(pos, button) { + var now = +new Date; + if (lastDoubleClick && lastDoubleClick.compare(now, pos, button)) { + lastClick = lastDoubleClick = null; + return "triple" + } else if (lastClick && lastClick.compare(now, pos, button)) { + lastDoubleClick = new PastClick(now, pos, button); + lastClick = null; + return "double" + } else { + lastClick = new PastClick(now, pos, button); + lastDoubleClick = null; + return "single" + } + } + + // A mouse down can be a single click, double click, triple click, + // start of selection drag, start of text drag, new cursor + // (ctrl-click), rectangle drag (alt-drag), or xwin + // middle-click-paste. Or it might be a click on something we should + // not interfere with, such as a scrollbar or widget. + function onMouseDown(e) { + var cm = this, display = cm.display; + if (signalDOMEvent(cm, e) || display.activeTouch && display.input.supportsTouch()) { return } + display.input.ensurePolled(); + display.shift = e.shiftKey; + + if (eventInWidget(display, e)) { + if (!webkit) { + // Briefly turn off draggability, to allow widgets to do + // normal dragging things. + display.scroller.draggable = false; + setTimeout(function () { return display.scroller.draggable = true; }, 100); + } + return + } + if (clickInGutter(cm, e)) { return } + var pos = posFromMouse(cm, e), button = e_button(e), repeat = pos ? clickRepeat(pos, button) : "single"; + win(cm).focus(); + + // #3261: make sure, that we're not starting a second selection + if (button == 1 && cm.state.selectingText) + { cm.state.selectingText(e); } + + if (pos && handleMappedButton(cm, button, pos, repeat, e)) { return } + + if (button == 1) { + if (pos) { leftButtonDown(cm, pos, repeat, e); } + else if (e_target(e) == display.scroller) { e_preventDefault(e); } + } else if (button == 2) { + if (pos) { extendSelection(cm.doc, pos); } + setTimeout(function () { return display.input.focus(); }, 20); + } else if (button == 3) { + if (captureRightClick) { cm.display.input.onContextMenu(e); } + else { delayBlurEvent(cm); } + } + } + + function handleMappedButton(cm, button, pos, repeat, event) { + var name = "Click"; + if (repeat == "double") { name = "Double" + name; } + else if (repeat == "triple") { name = "Triple" + name; } + name = (button == 1 ? "Left" : button == 2 ? "Middle" : "Right") + name; + + return dispatchKey(cm, addModifierNames(name, event), event, function (bound) { + if (typeof bound == "string") { bound = commands[bound]; } + if (!bound) { return false } + var done = false; + try { + if (cm.isReadOnly()) { cm.state.suppressEdits = true; } + done = bound(cm, pos) != Pass; + } finally { + cm.state.suppressEdits = false; + } + return done + }) + } + + function configureMouse(cm, repeat, event) { + var option = cm.getOption("configureMouse"); + var value = option ? option(cm, repeat, event) : {}; + if (value.unit == null) { + var rect = chromeOS ? event.shiftKey && event.metaKey : event.altKey; + value.unit = rect ? "rectangle" : repeat == "single" ? "char" : repeat == "double" ? "word" : "line"; + } + if (value.extend == null || cm.doc.extend) { value.extend = cm.doc.extend || event.shiftKey; } + if (value.addNew == null) { value.addNew = mac ? event.metaKey : event.ctrlKey; } + if (value.moveOnDrag == null) { value.moveOnDrag = !(mac ? event.altKey : event.ctrlKey); } + return value + } + + function leftButtonDown(cm, pos, repeat, event) { + if (ie) { setTimeout(bind(ensureFocus, cm), 0); } + else { cm.curOp.focus = activeElt(root(cm)); } + + var behavior = configureMouse(cm, repeat, event); + + var sel = cm.doc.sel, contained; + if (cm.options.dragDrop && dragAndDrop && !cm.isReadOnly() && + repeat == "single" && (contained = sel.contains(pos)) > -1 && + (cmp((contained = sel.ranges[contained]).from(), pos) < 0 || pos.xRel > 0) && + (cmp(contained.to(), pos) > 0 || pos.xRel < 0)) + { leftButtonStartDrag(cm, event, pos, behavior); } + else + { leftButtonSelect(cm, event, pos, behavior); } + } + + // Start a text drag. When it ends, see if any dragging actually + // happen, and treat as a click if it didn't. + function leftButtonStartDrag(cm, event, pos, behavior) { + var display = cm.display, moved = false; + var dragEnd = operation(cm, function (e) { + if (webkit) { display.scroller.draggable = false; } + cm.state.draggingText = false; + if (cm.state.delayingBlurEvent) { + if (cm.hasFocus()) { cm.state.delayingBlurEvent = false; } + else { delayBlurEvent(cm); } + } + off(display.wrapper.ownerDocument, "mouseup", dragEnd); + off(display.wrapper.ownerDocument, "mousemove", mouseMove); + off(display.scroller, "dragstart", dragStart); + off(display.scroller, "drop", dragEnd); + if (!moved) { + e_preventDefault(e); + if (!behavior.addNew) + { extendSelection(cm.doc, pos, null, null, behavior.extend); } + // Work around unexplainable focus problem in IE9 (#2127) and Chrome (#3081) + if ((webkit && !safari) || ie && ie_version == 9) + { setTimeout(function () {display.wrapper.ownerDocument.body.focus({preventScroll: true}); display.input.focus();}, 20); } + else + { display.input.focus(); } + } + }); + var mouseMove = function(e2) { + moved = moved || Math.abs(event.clientX - e2.clientX) + Math.abs(event.clientY - e2.clientY) >= 10; + }; + var dragStart = function () { return moved = true; }; + // Let the drag handler handle this. + if (webkit) { display.scroller.draggable = true; } + cm.state.draggingText = dragEnd; + dragEnd.copy = !behavior.moveOnDrag; + on(display.wrapper.ownerDocument, "mouseup", dragEnd); + on(display.wrapper.ownerDocument, "mousemove", mouseMove); + on(display.scroller, "dragstart", dragStart); + on(display.scroller, "drop", dragEnd); + + cm.state.delayingBlurEvent = true; + setTimeout(function () { return display.input.focus(); }, 20); + // IE's approach to draggable + if (display.scroller.dragDrop) { display.scroller.dragDrop(); } + } + + function rangeForUnit(cm, pos, unit) { + if (unit == "char") { return new Range(pos, pos) } + if (unit == "word") { return cm.findWordAt(pos) } + if (unit == "line") { return new Range(Pos(pos.line, 0), clipPos(cm.doc, Pos(pos.line + 1, 0))) } + var result = unit(cm, pos); + return new Range(result.from, result.to) + } + + // Normal selection, as opposed to text dragging. + function leftButtonSelect(cm, event, start, behavior) { + if (ie) { delayBlurEvent(cm); } + var display = cm.display, doc = cm.doc; + e_preventDefault(event); + + var ourRange, ourIndex, startSel = doc.sel, ranges = startSel.ranges; + if (behavior.addNew && !behavior.extend) { + ourIndex = doc.sel.contains(start); + if (ourIndex > -1) + { ourRange = ranges[ourIndex]; } + else + { ourRange = new Range(start, start); } + } else { + ourRange = doc.sel.primary(); + ourIndex = doc.sel.primIndex; + } + + if (behavior.unit == "rectangle") { + if (!behavior.addNew) { ourRange = new Range(start, start); } + start = posFromMouse(cm, event, true, true); + ourIndex = -1; + } else { + var range = rangeForUnit(cm, start, behavior.unit); + if (behavior.extend) + { ourRange = extendRange(ourRange, range.anchor, range.head, behavior.extend); } + else + { ourRange = range; } + } + + if (!behavior.addNew) { + ourIndex = 0; + setSelection(doc, new Selection([ourRange], 0), sel_mouse); + startSel = doc.sel; + } else if (ourIndex == -1) { + ourIndex = ranges.length; + setSelection(doc, normalizeSelection(cm, ranges.concat([ourRange]), ourIndex), + {scroll: false, origin: "*mouse"}); + } else if (ranges.length > 1 && ranges[ourIndex].empty() && behavior.unit == "char" && !behavior.extend) { + setSelection(doc, normalizeSelection(cm, ranges.slice(0, ourIndex).concat(ranges.slice(ourIndex + 1)), 0), + {scroll: false, origin: "*mouse"}); + startSel = doc.sel; + } else { + replaceOneSelection(doc, ourIndex, ourRange, sel_mouse); + } + + var lastPos = start; + function extendTo(pos) { + if (cmp(lastPos, pos) == 0) { return } + lastPos = pos; + + if (behavior.unit == "rectangle") { + var ranges = [], tabSize = cm.options.tabSize; + var startCol = countColumn(getLine(doc, start.line).text, start.ch, tabSize); + var posCol = countColumn(getLine(doc, pos.line).text, pos.ch, tabSize); + var left = Math.min(startCol, posCol), right = Math.max(startCol, posCol); + for (var line = Math.min(start.line, pos.line), end = Math.min(cm.lastLine(), Math.max(start.line, pos.line)); + line <= end; line++) { + var text = getLine(doc, line).text, leftPos = findColumn(text, left, tabSize); + if (left == right) + { ranges.push(new Range(Pos(line, leftPos), Pos(line, leftPos))); } + else if (text.length > leftPos) + { ranges.push(new Range(Pos(line, leftPos), Pos(line, findColumn(text, right, tabSize)))); } + } + if (!ranges.length) { ranges.push(new Range(start, start)); } + setSelection(doc, normalizeSelection(cm, startSel.ranges.slice(0, ourIndex).concat(ranges), ourIndex), + {origin: "*mouse", scroll: false}); + cm.scrollIntoView(pos); + } else { + var oldRange = ourRange; + var range = rangeForUnit(cm, pos, behavior.unit); + var anchor = oldRange.anchor, head; + if (cmp(range.anchor, anchor) > 0) { + head = range.head; + anchor = minPos(oldRange.from(), range.anchor); + } else { + head = range.anchor; + anchor = maxPos(oldRange.to(), range.head); + } + var ranges$1 = startSel.ranges.slice(0); + ranges$1[ourIndex] = bidiSimplify(cm, new Range(clipPos(doc, anchor), head)); + setSelection(doc, normalizeSelection(cm, ranges$1, ourIndex), sel_mouse); + } + } + + var editorSize = display.wrapper.getBoundingClientRect(); + // Used to ensure timeout re-tries don't fire when another extend + // happened in the meantime (clearTimeout isn't reliable -- at + // least on Chrome, the timeouts still happen even when cleared, + // if the clear happens after their scheduled firing time). + var counter = 0; + + function extend(e) { + var curCount = ++counter; + var cur = posFromMouse(cm, e, true, behavior.unit == "rectangle"); + if (!cur) { return } + if (cmp(cur, lastPos) != 0) { + cm.curOp.focus = activeElt(root(cm)); + extendTo(cur); + var visible = visibleLines(display, doc); + if (cur.line >= visible.to || cur.line < visible.from) + { setTimeout(operation(cm, function () {if (counter == curCount) { extend(e); }}), 150); } + } else { + var outside = e.clientY < editorSize.top ? -20 : e.clientY > editorSize.bottom ? 20 : 0; + if (outside) { setTimeout(operation(cm, function () { + if (counter != curCount) { return } + display.scroller.scrollTop += outside; + extend(e); + }), 50); } + } + } + + function done(e) { + cm.state.selectingText = false; + counter = Infinity; + // If e is null or undefined we interpret this as someone trying + // to explicitly cancel the selection rather than the user + // letting go of the mouse button. + if (e) { + e_preventDefault(e); + display.input.focus(); + } + off(display.wrapper.ownerDocument, "mousemove", move); + off(display.wrapper.ownerDocument, "mouseup", up); + doc.history.lastSelOrigin = null; + } + + var move = operation(cm, function (e) { + if (e.buttons === 0 || !e_button(e)) { done(e); } + else { extend(e); } + }); + var up = operation(cm, done); + cm.state.selectingText = up; + on(display.wrapper.ownerDocument, "mousemove", move); + on(display.wrapper.ownerDocument, "mouseup", up); + } + + // Used when mouse-selecting to adjust the anchor to the proper side + // of a bidi jump depending on the visual position of the head. + function bidiSimplify(cm, range) { + var anchor = range.anchor; + var head = range.head; + var anchorLine = getLine(cm.doc, anchor.line); + if (cmp(anchor, head) == 0 && anchor.sticky == head.sticky) { return range } + var order = getOrder(anchorLine); + if (!order) { return range } + var index = getBidiPartAt(order, anchor.ch, anchor.sticky), part = order[index]; + if (part.from != anchor.ch && part.to != anchor.ch) { return range } + var boundary = index + ((part.from == anchor.ch) == (part.level != 1) ? 0 : 1); + if (boundary == 0 || boundary == order.length) { return range } + + // Compute the relative visual position of the head compared to the + // anchor (<0 is to the left, >0 to the right) + var leftSide; + if (head.line != anchor.line) { + leftSide = (head.line - anchor.line) * (cm.doc.direction == "ltr" ? 1 : -1) > 0; + } else { + var headIndex = getBidiPartAt(order, head.ch, head.sticky); + var dir = headIndex - index || (head.ch - anchor.ch) * (part.level == 1 ? -1 : 1); + if (headIndex == boundary - 1 || headIndex == boundary) + { leftSide = dir < 0; } + else + { leftSide = dir > 0; } + } + + var usePart = order[boundary + (leftSide ? -1 : 0)]; + var from = leftSide == (usePart.level == 1); + var ch = from ? usePart.from : usePart.to, sticky = from ? "after" : "before"; + return anchor.ch == ch && anchor.sticky == sticky ? range : new Range(new Pos(anchor.line, ch, sticky), head) + } + + + // Determines whether an event happened in the gutter, and fires the + // handlers for the corresponding event. + function gutterEvent(cm, e, type, prevent) { + var mX, mY; + if (e.touches) { + mX = e.touches[0].clientX; + mY = e.touches[0].clientY; + } else { + try { mX = e.clientX; mY = e.clientY; } + catch(e$1) { return false } + } + if (mX >= Math.floor(cm.display.gutters.getBoundingClientRect().right)) { return false } + if (prevent) { e_preventDefault(e); } + + var display = cm.display; + var lineBox = display.lineDiv.getBoundingClientRect(); + + if (mY > lineBox.bottom || !hasHandler(cm, type)) { return e_defaultPrevented(e) } + mY -= lineBox.top - display.viewOffset; + + for (var i = 0; i < cm.display.gutterSpecs.length; ++i) { + var g = display.gutters.childNodes[i]; + if (g && g.getBoundingClientRect().right >= mX) { + var line = lineAtHeight(cm.doc, mY); + var gutter = cm.display.gutterSpecs[i]; + signal(cm, type, cm, line, gutter.className, e); + return e_defaultPrevented(e) + } + } + } + + function clickInGutter(cm, e) { + return gutterEvent(cm, e, "gutterClick", true) + } + + // CONTEXT MENU HANDLING + + // To make the context menu work, we need to briefly unhide the + // textarea (making it as unobtrusive as possible) to let the + // right-click take effect on it. + function onContextMenu(cm, e) { + if (eventInWidget(cm.display, e) || contextMenuInGutter(cm, e)) { return } + if (signalDOMEvent(cm, e, "contextmenu")) { return } + if (!captureRightClick) { cm.display.input.onContextMenu(e); } + } + + function contextMenuInGutter(cm, e) { + if (!hasHandler(cm, "gutterContextMenu")) { return false } + return gutterEvent(cm, e, "gutterContextMenu", false) + } + + function themeChanged(cm) { + cm.display.wrapper.className = cm.display.wrapper.className.replace(/\s*cm-s-\S+/g, "") + + cm.options.theme.replace(/(^|\s)\s*/g, " cm-s-"); + clearCaches(cm); + } + + var Init = {toString: function(){return "CodeMirror.Init"}}; + + var defaults = {}; + var optionHandlers = {}; + + function defineOptions(CodeMirror) { + var optionHandlers = CodeMirror.optionHandlers; + + function option(name, deflt, handle, notOnInit) { + CodeMirror.defaults[name] = deflt; + if (handle) { optionHandlers[name] = + notOnInit ? function (cm, val, old) {if (old != Init) { handle(cm, val, old); }} : handle; } + } + + CodeMirror.defineOption = option; + + // Passed to option handlers when there is no old value. + CodeMirror.Init = Init; + + // These two are, on init, called from the constructor because they + // have to be initialized before the editor can start at all. + option("value", "", function (cm, val) { return cm.setValue(val); }, true); + option("mode", null, function (cm, val) { + cm.doc.modeOption = val; + loadMode(cm); + }, true); + + option("indentUnit", 2, loadMode, true); + option("indentWithTabs", false); + option("smartIndent", true); + option("tabSize", 4, function (cm) { + resetModeState(cm); + clearCaches(cm); + regChange(cm); + }, true); + + option("lineSeparator", null, function (cm, val) { + cm.doc.lineSep = val; + if (!val) { return } + var newBreaks = [], lineNo = cm.doc.first; + cm.doc.iter(function (line) { + for (var pos = 0;;) { + var found = line.text.indexOf(val, pos); + if (found == -1) { break } + pos = found + val.length; + newBreaks.push(Pos(lineNo, found)); + } + lineNo++; + }); + for (var i = newBreaks.length - 1; i >= 0; i--) + { replaceRange(cm.doc, val, newBreaks[i], Pos(newBreaks[i].line, newBreaks[i].ch + val.length)); } + }); + option("specialChars", /[\u0000-\u001f\u007f-\u009f\u00ad\u061c\u200b\u200e\u200f\u2028\u2029\u202d\u202e\u2066\u2067\u2069\ufeff\ufff9-\ufffc]/g, function (cm, val, old) { + cm.state.specialChars = new RegExp(val.source + (val.test("\t") ? "" : "|\t"), "g"); + if (old != Init) { cm.refresh(); } + }); + option("specialCharPlaceholder", defaultSpecialCharPlaceholder, function (cm) { return cm.refresh(); }, true); + option("electricChars", true); + option("inputStyle", mobile ? "contenteditable" : "textarea", function () { + throw new Error("inputStyle can not (yet) be changed in a running editor") // FIXME + }, true); + option("spellcheck", false, function (cm, val) { return cm.getInputField().spellcheck = val; }, true); + option("autocorrect", false, function (cm, val) { return cm.getInputField().autocorrect = val; }, true); + option("autocapitalize", false, function (cm, val) { return cm.getInputField().autocapitalize = val; }, true); + option("rtlMoveVisually", !windows); + option("wholeLineUpdateBefore", true); + + option("theme", "default", function (cm) { + themeChanged(cm); + updateGutters(cm); + }, true); + option("keyMap", "default", function (cm, val, old) { + var next = getKeyMap(val); + var prev = old != Init && getKeyMap(old); + if (prev && prev.detach) { prev.detach(cm, next); } + if (next.attach) { next.attach(cm, prev || null); } + }); + option("extraKeys", null); + option("configureMouse", null); + + option("lineWrapping", false, wrappingChanged, true); + option("gutters", [], function (cm, val) { + cm.display.gutterSpecs = getGutters(val, cm.options.lineNumbers); + updateGutters(cm); + }, true); + option("fixedGutter", true, function (cm, val) { + cm.display.gutters.style.left = val ? compensateForHScroll(cm.display) + "px" : "0"; + cm.refresh(); + }, true); + option("coverGutterNextToScrollbar", false, function (cm) { return updateScrollbars(cm); }, true); + option("scrollbarStyle", "native", function (cm) { + initScrollbars(cm); + updateScrollbars(cm); + cm.display.scrollbars.setScrollTop(cm.doc.scrollTop); + cm.display.scrollbars.setScrollLeft(cm.doc.scrollLeft); + }, true); + option("lineNumbers", false, function (cm, val) { + cm.display.gutterSpecs = getGutters(cm.options.gutters, val); + updateGutters(cm); + }, true); + option("firstLineNumber", 1, updateGutters, true); + option("lineNumberFormatter", function (integer) { return integer; }, updateGutters, true); + option("showCursorWhenSelecting", false, updateSelection, true); + + option("resetSelectionOnContextMenu", true); + option("lineWiseCopyCut", true); + option("pasteLinesPerSelection", true); + option("selectionsMayTouch", false); + + option("readOnly", false, function (cm, val) { + if (val == "nocursor") { + onBlur(cm); + cm.display.input.blur(); + } + cm.display.input.readOnlyChanged(val); + }); + + option("screenReaderLabel", null, function (cm, val) { + val = (val === '') ? null : val; + cm.display.input.screenReaderLabelChanged(val); + }); + + option("disableInput", false, function (cm, val) {if (!val) { cm.display.input.reset(); }}, true); + option("dragDrop", true, dragDropChanged); + option("allowDropFileTypes", null); + + option("cursorBlinkRate", 530); + option("cursorScrollMargin", 0); + option("cursorHeight", 1, updateSelection, true); + option("singleCursorHeightPerLine", true, updateSelection, true); + option("workTime", 100); + option("workDelay", 100); + option("flattenSpans", true, resetModeState, true); + option("addModeClass", false, resetModeState, true); + option("pollInterval", 100); + option("undoDepth", 200, function (cm, val) { return cm.doc.history.undoDepth = val; }); + option("historyEventDelay", 1250); + option("viewportMargin", 10, function (cm) { return cm.refresh(); }, true); + option("maxHighlightLength", 10000, resetModeState, true); + option("moveInputWithCursor", true, function (cm, val) { + if (!val) { cm.display.input.resetPosition(); } + }); + + option("tabindex", null, function (cm, val) { return cm.display.input.getField().tabIndex = val || ""; }); + option("autofocus", null); + option("direction", "ltr", function (cm, val) { return cm.doc.setDirection(val); }, true); + option("phrases", null); + } + + function dragDropChanged(cm, value, old) { + var wasOn = old && old != Init; + if (!value != !wasOn) { + var funcs = cm.display.dragFunctions; + var toggle = value ? on : off; + toggle(cm.display.scroller, "dragstart", funcs.start); + toggle(cm.display.scroller, "dragenter", funcs.enter); + toggle(cm.display.scroller, "dragover", funcs.over); + toggle(cm.display.scroller, "dragleave", funcs.leave); + toggle(cm.display.scroller, "drop", funcs.drop); + } + } + + function wrappingChanged(cm) { + if (cm.options.lineWrapping) { + addClass(cm.display.wrapper, "CodeMirror-wrap"); + cm.display.sizer.style.minWidth = ""; + cm.display.sizerWidth = null; + } else { + rmClass(cm.display.wrapper, "CodeMirror-wrap"); + findMaxLine(cm); + } + estimateLineHeights(cm); + regChange(cm); + clearCaches(cm); + setTimeout(function () { return updateScrollbars(cm); }, 100); + } + + // A CodeMirror instance represents an editor. This is the object + // that user code is usually dealing with. + + function CodeMirror(place, options) { + var this$1 = this; + + if (!(this instanceof CodeMirror)) { return new CodeMirror(place, options) } + + this.options = options = options ? copyObj(options) : {}; + // Determine effective options based on given values and defaults. + copyObj(defaults, options, false); + + var doc = options.value; + if (typeof doc == "string") { doc = new Doc(doc, options.mode, null, options.lineSeparator, options.direction); } + else if (options.mode) { doc.modeOption = options.mode; } + this.doc = doc; + + var input = new CodeMirror.inputStyles[options.inputStyle](this); + var display = this.display = new Display(place, doc, input, options); + display.wrapper.CodeMirror = this; + themeChanged(this); + if (options.lineWrapping) + { this.display.wrapper.className += " CodeMirror-wrap"; } + initScrollbars(this); + + this.state = { + keyMaps: [], // stores maps added by addKeyMap + overlays: [], // highlighting overlays, as added by addOverlay + modeGen: 0, // bumped when mode/overlay changes, used to invalidate highlighting info + overwrite: false, + delayingBlurEvent: false, + focused: false, + suppressEdits: false, // used to disable editing during key handlers when in readOnly mode + pasteIncoming: -1, cutIncoming: -1, // help recognize paste/cut edits in input.poll + selectingText: false, + draggingText: false, + highlight: new Delayed(), // stores highlight worker timeout + keySeq: null, // Unfinished key sequence + specialChars: null + }; + + if (options.autofocus && !mobile) { display.input.focus(); } + + // Override magic textarea content restore that IE sometimes does + // on our hidden textarea on reload + if (ie && ie_version < 11) { setTimeout(function () { return this$1.display.input.reset(true); }, 20); } + + registerEventHandlers(this); + ensureGlobalHandlers(); + + startOperation(this); + this.curOp.forceUpdate = true; + attachDoc(this, doc); + + if ((options.autofocus && !mobile) || this.hasFocus()) + { setTimeout(function () { + if (this$1.hasFocus() && !this$1.state.focused) { onFocus(this$1); } + }, 20); } + else + { onBlur(this); } + + for (var opt in optionHandlers) { if (optionHandlers.hasOwnProperty(opt)) + { optionHandlers[opt](this, options[opt], Init); } } + maybeUpdateLineNumberWidth(this); + if (options.finishInit) { options.finishInit(this); } + for (var i = 0; i < initHooks.length; ++i) { initHooks[i](this); } + endOperation(this); + // Suppress optimizelegibility in Webkit, since it breaks text + // measuring on line wrapping boundaries. + if (webkit && options.lineWrapping && + getComputedStyle(display.lineDiv).textRendering == "optimizelegibility") + { display.lineDiv.style.textRendering = "auto"; } + } + + // The default configuration options. + CodeMirror.defaults = defaults; + // Functions to run when options are changed. + CodeMirror.optionHandlers = optionHandlers; + + // Attach the necessary event handlers when initializing the editor + function registerEventHandlers(cm) { + var d = cm.display; + on(d.scroller, "mousedown", operation(cm, onMouseDown)); + // Older IE's will not fire a second mousedown for a double click + if (ie && ie_version < 11) + { on(d.scroller, "dblclick", operation(cm, function (e) { + if (signalDOMEvent(cm, e)) { return } + var pos = posFromMouse(cm, e); + if (!pos || clickInGutter(cm, e) || eventInWidget(cm.display, e)) { return } + e_preventDefault(e); + var word = cm.findWordAt(pos); + extendSelection(cm.doc, word.anchor, word.head); + })); } + else + { on(d.scroller, "dblclick", function (e) { return signalDOMEvent(cm, e) || e_preventDefault(e); }); } + // Some browsers fire contextmenu *after* opening the menu, at + // which point we can't mess with it anymore. Context menu is + // handled in onMouseDown for these browsers. + on(d.scroller, "contextmenu", function (e) { return onContextMenu(cm, e); }); + on(d.input.getField(), "contextmenu", function (e) { + if (!d.scroller.contains(e.target)) { onContextMenu(cm, e); } + }); + + // Used to suppress mouse event handling when a touch happens + var touchFinished, prevTouch = {end: 0}; + function finishTouch() { + if (d.activeTouch) { + touchFinished = setTimeout(function () { return d.activeTouch = null; }, 1000); + prevTouch = d.activeTouch; + prevTouch.end = +new Date; + } + } + function isMouseLikeTouchEvent(e) { + if (e.touches.length != 1) { return false } + var touch = e.touches[0]; + return touch.radiusX <= 1 && touch.radiusY <= 1 + } + function farAway(touch, other) { + if (other.left == null) { return true } + var dx = other.left - touch.left, dy = other.top - touch.top; + return dx * dx + dy * dy > 20 * 20 + } + on(d.scroller, "touchstart", function (e) { + if (!signalDOMEvent(cm, e) && !isMouseLikeTouchEvent(e) && !clickInGutter(cm, e)) { + d.input.ensurePolled(); + clearTimeout(touchFinished); + var now = +new Date; + d.activeTouch = {start: now, moved: false, + prev: now - prevTouch.end <= 300 ? prevTouch : null}; + if (e.touches.length == 1) { + d.activeTouch.left = e.touches[0].pageX; + d.activeTouch.top = e.touches[0].pageY; + } + } + }); + on(d.scroller, "touchmove", function () { + if (d.activeTouch) { d.activeTouch.moved = true; } + }); + on(d.scroller, "touchend", function (e) { + var touch = d.activeTouch; + if (touch && !eventInWidget(d, e) && touch.left != null && + !touch.moved && new Date - touch.start < 300) { + var pos = cm.coordsChar(d.activeTouch, "page"), range; + if (!touch.prev || farAway(touch, touch.prev)) // Single tap + { range = new Range(pos, pos); } + else if (!touch.prev.prev || farAway(touch, touch.prev.prev)) // Double tap + { range = cm.findWordAt(pos); } + else // Triple tap + { range = new Range(Pos(pos.line, 0), clipPos(cm.doc, Pos(pos.line + 1, 0))); } + cm.setSelection(range.anchor, range.head); + cm.focus(); + e_preventDefault(e); + } + finishTouch(); + }); + on(d.scroller, "touchcancel", finishTouch); + + // Sync scrolling between fake scrollbars and real scrollable + // area, ensure viewport is updated when scrolling. + on(d.scroller, "scroll", function () { + if (d.scroller.clientHeight) { + updateScrollTop(cm, d.scroller.scrollTop); + setScrollLeft(cm, d.scroller.scrollLeft, true); + signal(cm, "scroll", cm); + } + }); + + // Listen to wheel events in order to try and update the viewport on time. + on(d.scroller, "mousewheel", function (e) { return onScrollWheel(cm, e); }); + on(d.scroller, "DOMMouseScroll", function (e) { return onScrollWheel(cm, e); }); + + // Prevent wrapper from ever scrolling + on(d.wrapper, "scroll", function () { return d.wrapper.scrollTop = d.wrapper.scrollLeft = 0; }); + + d.dragFunctions = { + enter: function (e) {if (!signalDOMEvent(cm, e)) { e_stop(e); }}, + over: function (e) {if (!signalDOMEvent(cm, e)) { onDragOver(cm, e); e_stop(e); }}, + start: function (e) { return onDragStart(cm, e); }, + drop: operation(cm, onDrop), + leave: function (e) {if (!signalDOMEvent(cm, e)) { clearDragCursor(cm); }} + }; + + var inp = d.input.getField(); + on(inp, "keyup", function (e) { return onKeyUp.call(cm, e); }); + on(inp, "keydown", operation(cm, onKeyDown)); + on(inp, "keypress", operation(cm, onKeyPress)); + on(inp, "focus", function (e) { return onFocus(cm, e); }); + on(inp, "blur", function (e) { return onBlur(cm, e); }); + } + + var initHooks = []; + CodeMirror.defineInitHook = function (f) { return initHooks.push(f); }; + + // Indent the given line. The how parameter can be "smart", + // "add"/null, "subtract", or "prev". When aggressive is false + // (typically set to true for forced single-line indents), empty + // lines are not indented, and places where the mode returns Pass + // are left alone. + function indentLine(cm, n, how, aggressive) { + var doc = cm.doc, state; + if (how == null) { how = "add"; } + if (how == "smart") { + // Fall back to "prev" when the mode doesn't have an indentation + // method. + if (!doc.mode.indent) { how = "prev"; } + else { state = getContextBefore(cm, n).state; } + } + + var tabSize = cm.options.tabSize; + var line = getLine(doc, n), curSpace = countColumn(line.text, null, tabSize); + if (line.stateAfter) { line.stateAfter = null; } + var curSpaceString = line.text.match(/^\s*/)[0], indentation; + if (!aggressive && !/\S/.test(line.text)) { + indentation = 0; + how = "not"; + } else if (how == "smart") { + indentation = doc.mode.indent(state, line.text.slice(curSpaceString.length), line.text); + if (indentation == Pass || indentation > 150) { + if (!aggressive) { return } + how = "prev"; + } + } + if (how == "prev") { + if (n > doc.first) { indentation = countColumn(getLine(doc, n-1).text, null, tabSize); } + else { indentation = 0; } + } else if (how == "add") { + indentation = curSpace + cm.options.indentUnit; + } else if (how == "subtract") { + indentation = curSpace - cm.options.indentUnit; + } else if (typeof how == "number") { + indentation = curSpace + how; + } + indentation = Math.max(0, indentation); + + var indentString = "", pos = 0; + if (cm.options.indentWithTabs) + { for (var i = Math.floor(indentation / tabSize); i; --i) {pos += tabSize; indentString += "\t";} } + if (pos < indentation) { indentString += spaceStr(indentation - pos); } + + if (indentString != curSpaceString) { + replaceRange(doc, indentString, Pos(n, 0), Pos(n, curSpaceString.length), "+input"); + line.stateAfter = null; + return true + } else { + // Ensure that, if the cursor was in the whitespace at the start + // of the line, it is moved to the end of that space. + for (var i$1 = 0; i$1 < doc.sel.ranges.length; i$1++) { + var range = doc.sel.ranges[i$1]; + if (range.head.line == n && range.head.ch < curSpaceString.length) { + var pos$1 = Pos(n, curSpaceString.length); + replaceOneSelection(doc, i$1, new Range(pos$1, pos$1)); + break + } + } + } + } + + // This will be set to a {lineWise: bool, text: [string]} object, so + // that, when pasting, we know what kind of selections the copied + // text was made out of. + var lastCopied = null; + + function setLastCopied(newLastCopied) { + lastCopied = newLastCopied; + } + + function applyTextInput(cm, inserted, deleted, sel, origin) { + var doc = cm.doc; + cm.display.shift = false; + if (!sel) { sel = doc.sel; } + + var recent = +new Date - 200; + var paste = origin == "paste" || cm.state.pasteIncoming > recent; + var textLines = splitLinesAuto(inserted), multiPaste = null; + // When pasting N lines into N selections, insert one line per selection + if (paste && sel.ranges.length > 1) { + if (lastCopied && lastCopied.text.join("\n") == inserted) { + if (sel.ranges.length % lastCopied.text.length == 0) { + multiPaste = []; + for (var i = 0; i < lastCopied.text.length; i++) + { multiPaste.push(doc.splitLines(lastCopied.text[i])); } + } + } else if (textLines.length == sel.ranges.length && cm.options.pasteLinesPerSelection) { + multiPaste = map(textLines, function (l) { return [l]; }); + } + } + + var updateInput = cm.curOp.updateInput; + // Normal behavior is to insert the new text into every selection + for (var i$1 = sel.ranges.length - 1; i$1 >= 0; i$1--) { + var range = sel.ranges[i$1]; + var from = range.from(), to = range.to(); + if (range.empty()) { + if (deleted && deleted > 0) // Handle deletion + { from = Pos(from.line, from.ch - deleted); } + else if (cm.state.overwrite && !paste) // Handle overwrite + { to = Pos(to.line, Math.min(getLine(doc, to.line).text.length, to.ch + lst(textLines).length)); } + else if (paste && lastCopied && lastCopied.lineWise && lastCopied.text.join("\n") == textLines.join("\n")) + { from = to = Pos(from.line, 0); } + } + var changeEvent = {from: from, to: to, text: multiPaste ? multiPaste[i$1 % multiPaste.length] : textLines, + origin: origin || (paste ? "paste" : cm.state.cutIncoming > recent ? "cut" : "+input")}; + makeChange(cm.doc, changeEvent); + signalLater(cm, "inputRead", cm, changeEvent); + } + if (inserted && !paste) + { triggerElectric(cm, inserted); } + + ensureCursorVisible(cm); + if (cm.curOp.updateInput < 2) { cm.curOp.updateInput = updateInput; } + cm.curOp.typing = true; + cm.state.pasteIncoming = cm.state.cutIncoming = -1; + } + + function handlePaste(e, cm) { + var pasted = e.clipboardData && e.clipboardData.getData("Text"); + if (pasted) { + e.preventDefault(); + if (!cm.isReadOnly() && !cm.options.disableInput && cm.hasFocus()) + { runInOp(cm, function () { return applyTextInput(cm, pasted, 0, null, "paste"); }); } + return true + } + } + + function triggerElectric(cm, inserted) { + // When an 'electric' character is inserted, immediately trigger a reindent + if (!cm.options.electricChars || !cm.options.smartIndent) { return } + var sel = cm.doc.sel; + + for (var i = sel.ranges.length - 1; i >= 0; i--) { + var range = sel.ranges[i]; + if (range.head.ch > 100 || (i && sel.ranges[i - 1].head.line == range.head.line)) { continue } + var mode = cm.getModeAt(range.head); + var indented = false; + if (mode.electricChars) { + for (var j = 0; j < mode.electricChars.length; j++) + { if (inserted.indexOf(mode.electricChars.charAt(j)) > -1) { + indented = indentLine(cm, range.head.line, "smart"); + break + } } + } else if (mode.electricInput) { + if (mode.electricInput.test(getLine(cm.doc, range.head.line).text.slice(0, range.head.ch))) + { indented = indentLine(cm, range.head.line, "smart"); } + } + if (indented) { signalLater(cm, "electricInput", cm, range.head.line); } + } + } + + function copyableRanges(cm) { + var text = [], ranges = []; + for (var i = 0; i < cm.doc.sel.ranges.length; i++) { + var line = cm.doc.sel.ranges[i].head.line; + var lineRange = {anchor: Pos(line, 0), head: Pos(line + 1, 0)}; + ranges.push(lineRange); + text.push(cm.getRange(lineRange.anchor, lineRange.head)); + } + return {text: text, ranges: ranges} + } + + function disableBrowserMagic(field, spellcheck, autocorrect, autocapitalize) { + field.setAttribute("autocorrect", autocorrect ? "on" : "off"); + field.setAttribute("autocapitalize", autocapitalize ? "on" : "off"); + field.setAttribute("spellcheck", !!spellcheck); + } + + function hiddenTextarea() { + var te = elt("textarea", null, null, "position: absolute; bottom: -1em; padding: 0; width: 1px; height: 1em; min-height: 1em; outline: none"); + var div = elt("div", [te], null, "overflow: hidden; position: relative; width: 3px; height: 0px;"); + // The textarea is kept positioned near the cursor to prevent the + // fact that it'll be scrolled into view on input from scrolling + // our fake cursor out of view. On webkit, when wrap=off, paste is + // very slow. So make the area wide instead. + if (webkit) { te.style.width = "1000px"; } + else { te.setAttribute("wrap", "off"); } + // If border: 0; -- iOS fails to open keyboard (issue #1287) + if (ios) { te.style.border = "1px solid black"; } + return div + } + + // The publicly visible API. Note that methodOp(f) means + // 'wrap f in an operation, performed on its `this` parameter'. + + // This is not the complete set of editor methods. Most of the + // methods defined on the Doc type are also injected into + // CodeMirror.prototype, for backwards compatibility and + // convenience. + + function addEditorMethods(CodeMirror) { + var optionHandlers = CodeMirror.optionHandlers; + + var helpers = CodeMirror.helpers = {}; + + CodeMirror.prototype = { + constructor: CodeMirror, + focus: function(){win(this).focus(); this.display.input.focus();}, + + setOption: function(option, value) { + var options = this.options, old = options[option]; + if (options[option] == value && option != "mode") { return } + options[option] = value; + if (optionHandlers.hasOwnProperty(option)) + { operation(this, optionHandlers[option])(this, value, old); } + signal(this, "optionChange", this, option); + }, + + getOption: function(option) {return this.options[option]}, + getDoc: function() {return this.doc}, + + addKeyMap: function(map, bottom) { + this.state.keyMaps[bottom ? "push" : "unshift"](getKeyMap(map)); + }, + removeKeyMap: function(map) { + var maps = this.state.keyMaps; + for (var i = 0; i < maps.length; ++i) + { if (maps[i] == map || maps[i].name == map) { + maps.splice(i, 1); + return true + } } + }, + + addOverlay: methodOp(function(spec, options) { + var mode = spec.token ? spec : CodeMirror.getMode(this.options, spec); + if (mode.startState) { throw new Error("Overlays may not be stateful.") } + insertSorted(this.state.overlays, + {mode: mode, modeSpec: spec, opaque: options && options.opaque, + priority: (options && options.priority) || 0}, + function (overlay) { return overlay.priority; }); + this.state.modeGen++; + regChange(this); + }), + removeOverlay: methodOp(function(spec) { + var overlays = this.state.overlays; + for (var i = 0; i < overlays.length; ++i) { + var cur = overlays[i].modeSpec; + if (cur == spec || typeof spec == "string" && cur.name == spec) { + overlays.splice(i, 1); + this.state.modeGen++; + regChange(this); + return + } + } + }), + + indentLine: methodOp(function(n, dir, aggressive) { + if (typeof dir != "string" && typeof dir != "number") { + if (dir == null) { dir = this.options.smartIndent ? "smart" : "prev"; } + else { dir = dir ? "add" : "subtract"; } + } + if (isLine(this.doc, n)) { indentLine(this, n, dir, aggressive); } + }), + indentSelection: methodOp(function(how) { + var ranges = this.doc.sel.ranges, end = -1; + for (var i = 0; i < ranges.length; i++) { + var range = ranges[i]; + if (!range.empty()) { + var from = range.from(), to = range.to(); + var start = Math.max(end, from.line); + end = Math.min(this.lastLine(), to.line - (to.ch ? 0 : 1)) + 1; + for (var j = start; j < end; ++j) + { indentLine(this, j, how); } + var newRanges = this.doc.sel.ranges; + if (from.ch == 0 && ranges.length == newRanges.length && newRanges[i].from().ch > 0) + { replaceOneSelection(this.doc, i, new Range(from, newRanges[i].to()), sel_dontScroll); } + } else if (range.head.line > end) { + indentLine(this, range.head.line, how, true); + end = range.head.line; + if (i == this.doc.sel.primIndex) { ensureCursorVisible(this); } + } + } + }), + + // Fetch the parser token for a given character. Useful for hacks + // that want to inspect the mode state (say, for completion). + getTokenAt: function(pos, precise) { + return takeToken(this, pos, precise) + }, + + getLineTokens: function(line, precise) { + return takeToken(this, Pos(line), precise, true) + }, + + getTokenTypeAt: function(pos) { + pos = clipPos(this.doc, pos); + var styles = getLineStyles(this, getLine(this.doc, pos.line)); + var before = 0, after = (styles.length - 1) / 2, ch = pos.ch; + var type; + if (ch == 0) { type = styles[2]; } + else { for (;;) { + var mid = (before + after) >> 1; + if ((mid ? styles[mid * 2 - 1] : 0) >= ch) { after = mid; } + else if (styles[mid * 2 + 1] < ch) { before = mid + 1; } + else { type = styles[mid * 2 + 2]; break } + } } + var cut = type ? type.indexOf("overlay ") : -1; + return cut < 0 ? type : cut == 0 ? null : type.slice(0, cut - 1) + }, + + getModeAt: function(pos) { + var mode = this.doc.mode; + if (!mode.innerMode) { return mode } + return CodeMirror.innerMode(mode, this.getTokenAt(pos).state).mode + }, + + getHelper: function(pos, type) { + return this.getHelpers(pos, type)[0] + }, + + getHelpers: function(pos, type) { + var found = []; + if (!helpers.hasOwnProperty(type)) { return found } + var help = helpers[type], mode = this.getModeAt(pos); + if (typeof mode[type] == "string") { + if (help[mode[type]]) { found.push(help[mode[type]]); } + } else if (mode[type]) { + for (var i = 0; i < mode[type].length; i++) { + var val = help[mode[type][i]]; + if (val) { found.push(val); } + } + } else if (mode.helperType && help[mode.helperType]) { + found.push(help[mode.helperType]); + } else if (help[mode.name]) { + found.push(help[mode.name]); + } + for (var i$1 = 0; i$1 < help._global.length; i$1++) { + var cur = help._global[i$1]; + if (cur.pred(mode, this) && indexOf(found, cur.val) == -1) + { found.push(cur.val); } + } + return found + }, + + getStateAfter: function(line, precise) { + var doc = this.doc; + line = clipLine(doc, line == null ? doc.first + doc.size - 1: line); + return getContextBefore(this, line + 1, precise).state + }, + + cursorCoords: function(start, mode) { + var pos, range = this.doc.sel.primary(); + if (start == null) { pos = range.head; } + else if (typeof start == "object") { pos = clipPos(this.doc, start); } + else { pos = start ? range.from() : range.to(); } + return cursorCoords(this, pos, mode || "page") + }, + + charCoords: function(pos, mode) { + return charCoords(this, clipPos(this.doc, pos), mode || "page") + }, + + coordsChar: function(coords, mode) { + coords = fromCoordSystem(this, coords, mode || "page"); + return coordsChar(this, coords.left, coords.top) + }, + + lineAtHeight: function(height, mode) { + height = fromCoordSystem(this, {top: height, left: 0}, mode || "page").top; + return lineAtHeight(this.doc, height + this.display.viewOffset) + }, + heightAtLine: function(line, mode, includeWidgets) { + var end = false, lineObj; + if (typeof line == "number") { + var last = this.doc.first + this.doc.size - 1; + if (line < this.doc.first) { line = this.doc.first; } + else if (line > last) { line = last; end = true; } + lineObj = getLine(this.doc, line); + } else { + lineObj = line; + } + return intoCoordSystem(this, lineObj, {top: 0, left: 0}, mode || "page", includeWidgets || end).top + + (end ? this.doc.height - heightAtLine(lineObj) : 0) + }, + + defaultTextHeight: function() { return textHeight(this.display) }, + defaultCharWidth: function() { return charWidth(this.display) }, + + getViewport: function() { return {from: this.display.viewFrom, to: this.display.viewTo}}, + + addWidget: function(pos, node, scroll, vert, horiz) { + var display = this.display; + pos = cursorCoords(this, clipPos(this.doc, pos)); + var top = pos.bottom, left = pos.left; + node.style.position = "absolute"; + node.setAttribute("cm-ignore-events", "true"); + this.display.input.setUneditable(node); + display.sizer.appendChild(node); + if (vert == "over") { + top = pos.top; + } else if (vert == "above" || vert == "near") { + var vspace = Math.max(display.wrapper.clientHeight, this.doc.height), + hspace = Math.max(display.sizer.clientWidth, display.lineSpace.clientWidth); + // Default to positioning above (if specified and possible); otherwise default to positioning below + if ((vert == 'above' || pos.bottom + node.offsetHeight > vspace) && pos.top > node.offsetHeight) + { top = pos.top - node.offsetHeight; } + else if (pos.bottom + node.offsetHeight <= vspace) + { top = pos.bottom; } + if (left + node.offsetWidth > hspace) + { left = hspace - node.offsetWidth; } + } + node.style.top = top + "px"; + node.style.left = node.style.right = ""; + if (horiz == "right") { + left = display.sizer.clientWidth - node.offsetWidth; + node.style.right = "0px"; + } else { + if (horiz == "left") { left = 0; } + else if (horiz == "middle") { left = (display.sizer.clientWidth - node.offsetWidth) / 2; } + node.style.left = left + "px"; + } + if (scroll) + { scrollIntoView(this, {left: left, top: top, right: left + node.offsetWidth, bottom: top + node.offsetHeight}); } + }, + + triggerOnKeyDown: methodOp(onKeyDown), + triggerOnKeyPress: methodOp(onKeyPress), + triggerOnKeyUp: onKeyUp, + triggerOnMouseDown: methodOp(onMouseDown), + + execCommand: function(cmd) { + if (commands.hasOwnProperty(cmd)) + { return commands[cmd].call(null, this) } + }, + + triggerElectric: methodOp(function(text) { triggerElectric(this, text); }), + + findPosH: function(from, amount, unit, visually) { + var dir = 1; + if (amount < 0) { dir = -1; amount = -amount; } + var cur = clipPos(this.doc, from); + for (var i = 0; i < amount; ++i) { + cur = findPosH(this.doc, cur, dir, unit, visually); + if (cur.hitSide) { break } + } + return cur + }, + + moveH: methodOp(function(dir, unit) { + var this$1 = this; + + this.extendSelectionsBy(function (range) { + if (this$1.display.shift || this$1.doc.extend || range.empty()) + { return findPosH(this$1.doc, range.head, dir, unit, this$1.options.rtlMoveVisually) } + else + { return dir < 0 ? range.from() : range.to() } + }, sel_move); + }), + + deleteH: methodOp(function(dir, unit) { + var sel = this.doc.sel, doc = this.doc; + if (sel.somethingSelected()) + { doc.replaceSelection("", null, "+delete"); } + else + { deleteNearSelection(this, function (range) { + var other = findPosH(doc, range.head, dir, unit, false); + return dir < 0 ? {from: other, to: range.head} : {from: range.head, to: other} + }); } + }), + + findPosV: function(from, amount, unit, goalColumn) { + var dir = 1, x = goalColumn; + if (amount < 0) { dir = -1; amount = -amount; } + var cur = clipPos(this.doc, from); + for (var i = 0; i < amount; ++i) { + var coords = cursorCoords(this, cur, "div"); + if (x == null) { x = coords.left; } + else { coords.left = x; } + cur = findPosV(this, coords, dir, unit); + if (cur.hitSide) { break } + } + return cur + }, + + moveV: methodOp(function(dir, unit) { + var this$1 = this; + + var doc = this.doc, goals = []; + var collapse = !this.display.shift && !doc.extend && doc.sel.somethingSelected(); + doc.extendSelectionsBy(function (range) { + if (collapse) + { return dir < 0 ? range.from() : range.to() } + var headPos = cursorCoords(this$1, range.head, "div"); + if (range.goalColumn != null) { headPos.left = range.goalColumn; } + goals.push(headPos.left); + var pos = findPosV(this$1, headPos, dir, unit); + if (unit == "page" && range == doc.sel.primary()) + { addToScrollTop(this$1, charCoords(this$1, pos, "div").top - headPos.top); } + return pos + }, sel_move); + if (goals.length) { for (var i = 0; i < doc.sel.ranges.length; i++) + { doc.sel.ranges[i].goalColumn = goals[i]; } } + }), + + // Find the word at the given position (as returned by coordsChar). + findWordAt: function(pos) { + var doc = this.doc, line = getLine(doc, pos.line).text; + var start = pos.ch, end = pos.ch; + if (line) { + var helper = this.getHelper(pos, "wordChars"); + if ((pos.sticky == "before" || end == line.length) && start) { --start; } else { ++end; } + var startChar = line.charAt(start); + var check = isWordChar(startChar, helper) + ? function (ch) { return isWordChar(ch, helper); } + : /\s/.test(startChar) ? function (ch) { return /\s/.test(ch); } + : function (ch) { return (!/\s/.test(ch) && !isWordChar(ch)); }; + while (start > 0 && check(line.charAt(start - 1))) { --start; } + while (end < line.length && check(line.charAt(end))) { ++end; } + } + return new Range(Pos(pos.line, start), Pos(pos.line, end)) + }, + + toggleOverwrite: function(value) { + if (value != null && value == this.state.overwrite) { return } + if (this.state.overwrite = !this.state.overwrite) + { addClass(this.display.cursorDiv, "CodeMirror-overwrite"); } + else + { rmClass(this.display.cursorDiv, "CodeMirror-overwrite"); } + + signal(this, "overwriteToggle", this, this.state.overwrite); + }, + hasFocus: function() { return this.display.input.getField() == activeElt(root(this)) }, + isReadOnly: function() { return !!(this.options.readOnly || this.doc.cantEdit) }, + + scrollTo: methodOp(function (x, y) { scrollToCoords(this, x, y); }), + getScrollInfo: function() { + var scroller = this.display.scroller; + return {left: scroller.scrollLeft, top: scroller.scrollTop, + height: scroller.scrollHeight - scrollGap(this) - this.display.barHeight, + width: scroller.scrollWidth - scrollGap(this) - this.display.barWidth, + clientHeight: displayHeight(this), clientWidth: displayWidth(this)} + }, + + scrollIntoView: methodOp(function(range, margin) { + if (range == null) { + range = {from: this.doc.sel.primary().head, to: null}; + if (margin == null) { margin = this.options.cursorScrollMargin; } + } else if (typeof range == "number") { + range = {from: Pos(range, 0), to: null}; + } else if (range.from == null) { + range = {from: range, to: null}; + } + if (!range.to) { range.to = range.from; } + range.margin = margin || 0; + + if (range.from.line != null) { + scrollToRange(this, range); + } else { + scrollToCoordsRange(this, range.from, range.to, range.margin); + } + }), + + setSize: methodOp(function(width, height) { + var this$1 = this; + + var interpret = function (val) { return typeof val == "number" || /^\d+$/.test(String(val)) ? val + "px" : val; }; + if (width != null) { this.display.wrapper.style.width = interpret(width); } + if (height != null) { this.display.wrapper.style.height = interpret(height); } + if (this.options.lineWrapping) { clearLineMeasurementCache(this); } + var lineNo = this.display.viewFrom; + this.doc.iter(lineNo, this.display.viewTo, function (line) { + if (line.widgets) { for (var i = 0; i < line.widgets.length; i++) + { if (line.widgets[i].noHScroll) { regLineChange(this$1, lineNo, "widget"); break } } } + ++lineNo; + }); + this.curOp.forceUpdate = true; + signal(this, "refresh", this); + }), + + operation: function(f){return runInOp(this, f)}, + startOperation: function(){return startOperation(this)}, + endOperation: function(){return endOperation(this)}, + + refresh: methodOp(function() { + var oldHeight = this.display.cachedTextHeight; + regChange(this); + this.curOp.forceUpdate = true; + clearCaches(this); + scrollToCoords(this, this.doc.scrollLeft, this.doc.scrollTop); + updateGutterSpace(this.display); + if (oldHeight == null || Math.abs(oldHeight - textHeight(this.display)) > .5 || this.options.lineWrapping) + { estimateLineHeights(this); } + signal(this, "refresh", this); + }), + + swapDoc: methodOp(function(doc) { + var old = this.doc; + old.cm = null; + // Cancel the current text selection if any (#5821) + if (this.state.selectingText) { this.state.selectingText(); } + attachDoc(this, doc); + clearCaches(this); + this.display.input.reset(); + scrollToCoords(this, doc.scrollLeft, doc.scrollTop); + this.curOp.forceScroll = true; + signalLater(this, "swapDoc", this, old); + return old + }), + + phrase: function(phraseText) { + var phrases = this.options.phrases; + return phrases && Object.prototype.hasOwnProperty.call(phrases, phraseText) ? phrases[phraseText] : phraseText + }, + + getInputField: function(){return this.display.input.getField()}, + getWrapperElement: function(){return this.display.wrapper}, + getScrollerElement: function(){return this.display.scroller}, + getGutterElement: function(){return this.display.gutters} + }; + eventMixin(CodeMirror); + + CodeMirror.registerHelper = function(type, name, value) { + if (!helpers.hasOwnProperty(type)) { helpers[type] = CodeMirror[type] = {_global: []}; } + helpers[type][name] = value; + }; + CodeMirror.registerGlobalHelper = function(type, name, predicate, value) { + CodeMirror.registerHelper(type, name, value); + helpers[type]._global.push({pred: predicate, val: value}); + }; + } + + // Used for horizontal relative motion. Dir is -1 or 1 (left or + // right), unit can be "codepoint", "char", "column" (like char, but + // doesn't cross line boundaries), "word" (across next word), or + // "group" (to the start of next group of word or + // non-word-non-whitespace chars). The visually param controls + // whether, in right-to-left text, direction 1 means to move towards + // the next index in the string, or towards the character to the right + // of the current position. The resulting position will have a + // hitSide=true property if it reached the end of the document. + function findPosH(doc, pos, dir, unit, visually) { + var oldPos = pos; + var origDir = dir; + var lineObj = getLine(doc, pos.line); + var lineDir = visually && doc.direction == "rtl" ? -dir : dir; + function findNextLine() { + var l = pos.line + lineDir; + if (l < doc.first || l >= doc.first + doc.size) { return false } + pos = new Pos(l, pos.ch, pos.sticky); + return lineObj = getLine(doc, l) + } + function moveOnce(boundToLine) { + var next; + if (unit == "codepoint") { + var ch = lineObj.text.charCodeAt(pos.ch + (dir > 0 ? 0 : -1)); + if (isNaN(ch)) { + next = null; + } else { + var astral = dir > 0 ? ch >= 0xD800 && ch < 0xDC00 : ch >= 0xDC00 && ch < 0xDFFF; + next = new Pos(pos.line, Math.max(0, Math.min(lineObj.text.length, pos.ch + dir * (astral ? 2 : 1))), -dir); + } + } else if (visually) { + next = moveVisually(doc.cm, lineObj, pos, dir); + } else { + next = moveLogically(lineObj, pos, dir); + } + if (next == null) { + if (!boundToLine && findNextLine()) + { pos = endOfLine(visually, doc.cm, lineObj, pos.line, lineDir); } + else + { return false } + } else { + pos = next; + } + return true + } + + if (unit == "char" || unit == "codepoint") { + moveOnce(); + } else if (unit == "column") { + moveOnce(true); + } else if (unit == "word" || unit == "group") { + var sawType = null, group = unit == "group"; + var helper = doc.cm && doc.cm.getHelper(pos, "wordChars"); + for (var first = true;; first = false) { + if (dir < 0 && !moveOnce(!first)) { break } + var cur = lineObj.text.charAt(pos.ch) || "\n"; + var type = isWordChar(cur, helper) ? "w" + : group && cur == "\n" ? "n" + : !group || /\s/.test(cur) ? null + : "p"; + if (group && !first && !type) { type = "s"; } + if (sawType && sawType != type) { + if (dir < 0) {dir = 1; moveOnce(); pos.sticky = "after";} + break + } + + if (type) { sawType = type; } + if (dir > 0 && !moveOnce(!first)) { break } + } + } + var result = skipAtomic(doc, pos, oldPos, origDir, true); + if (equalCursorPos(oldPos, result)) { result.hitSide = true; } + return result + } + + // For relative vertical movement. Dir may be -1 or 1. Unit can be + // "page" or "line". The resulting position will have a hitSide=true + // property if it reached the end of the document. + function findPosV(cm, pos, dir, unit) { + var doc = cm.doc, x = pos.left, y; + if (unit == "page") { + var pageSize = Math.min(cm.display.wrapper.clientHeight, win(cm).innerHeight || doc(cm).documentElement.clientHeight); + var moveAmount = Math.max(pageSize - .5 * textHeight(cm.display), 3); + y = (dir > 0 ? pos.bottom : pos.top) + dir * moveAmount; + + } else if (unit == "line") { + y = dir > 0 ? pos.bottom + 3 : pos.top - 3; + } + var target; + for (;;) { + target = coordsChar(cm, x, y); + if (!target.outside) { break } + if (dir < 0 ? y <= 0 : y >= doc.height) { target.hitSide = true; break } + y += dir * 5; + } + return target + } + + // CONTENTEDITABLE INPUT STYLE + + var ContentEditableInput = function(cm) { + this.cm = cm; + this.lastAnchorNode = this.lastAnchorOffset = this.lastFocusNode = this.lastFocusOffset = null; + this.polling = new Delayed(); + this.composing = null; + this.gracePeriod = false; + this.readDOMTimeout = null; + }; + + ContentEditableInput.prototype.init = function (display) { + var this$1 = this; + + var input = this, cm = input.cm; + var div = input.div = display.lineDiv; + div.contentEditable = true; + disableBrowserMagic(div, cm.options.spellcheck, cm.options.autocorrect, cm.options.autocapitalize); + + function belongsToInput(e) { + for (var t = e.target; t; t = t.parentNode) { + if (t == div) { return true } + if (/\bCodeMirror-(?:line)?widget\b/.test(t.className)) { break } + } + return false + } + + on(div, "paste", function (e) { + if (!belongsToInput(e) || signalDOMEvent(cm, e) || handlePaste(e, cm)) { return } + // IE doesn't fire input events, so we schedule a read for the pasted content in this way + if (ie_version <= 11) { setTimeout(operation(cm, function () { return this$1.updateFromDOM(); }), 20); } + }); + + on(div, "compositionstart", function (e) { + this$1.composing = {data: e.data, done: false}; + }); + on(div, "compositionupdate", function (e) { + if (!this$1.composing) { this$1.composing = {data: e.data, done: false}; } + }); + on(div, "compositionend", function (e) { + if (this$1.composing) { + if (e.data != this$1.composing.data) { this$1.readFromDOMSoon(); } + this$1.composing.done = true; + } + }); + + on(div, "touchstart", function () { return input.forceCompositionEnd(); }); + + on(div, "input", function () { + if (!this$1.composing) { this$1.readFromDOMSoon(); } + }); + + function onCopyCut(e) { + if (!belongsToInput(e) || signalDOMEvent(cm, e)) { return } + if (cm.somethingSelected()) { + setLastCopied({lineWise: false, text: cm.getSelections()}); + if (e.type == "cut") { cm.replaceSelection("", null, "cut"); } + } else if (!cm.options.lineWiseCopyCut) { + return + } else { + var ranges = copyableRanges(cm); + setLastCopied({lineWise: true, text: ranges.text}); + if (e.type == "cut") { + cm.operation(function () { + cm.setSelections(ranges.ranges, 0, sel_dontScroll); + cm.replaceSelection("", null, "cut"); + }); + } + } + if (e.clipboardData) { + e.clipboardData.clearData(); + var content = lastCopied.text.join("\n"); + // iOS exposes the clipboard API, but seems to discard content inserted into it + e.clipboardData.setData("Text", content); + if (e.clipboardData.getData("Text") == content) { + e.preventDefault(); + return + } + } + // Old-fashioned briefly-focus-a-textarea hack + var kludge = hiddenTextarea(), te = kludge.firstChild; + disableBrowserMagic(te); + cm.display.lineSpace.insertBefore(kludge, cm.display.lineSpace.firstChild); + te.value = lastCopied.text.join("\n"); + var hadFocus = activeElt(rootNode(div)); + selectInput(te); + setTimeout(function () { + cm.display.lineSpace.removeChild(kludge); + hadFocus.focus(); + if (hadFocus == div) { input.showPrimarySelection(); } + }, 50); + } + on(div, "copy", onCopyCut); + on(div, "cut", onCopyCut); + }; + + ContentEditableInput.prototype.screenReaderLabelChanged = function (label) { + // Label for screenreaders, accessibility + if(label) { + this.div.setAttribute('aria-label', label); + } else { + this.div.removeAttribute('aria-label'); + } + }; + + ContentEditableInput.prototype.prepareSelection = function () { + var result = prepareSelection(this.cm, false); + result.focus = activeElt(rootNode(this.div)) == this.div; + return result + }; + + ContentEditableInput.prototype.showSelection = function (info, takeFocus) { + if (!info || !this.cm.display.view.length) { return } + if (info.focus || takeFocus) { this.showPrimarySelection(); } + this.showMultipleSelections(info); + }; + + ContentEditableInput.prototype.getSelection = function () { + return this.cm.display.wrapper.ownerDocument.getSelection() + }; + + ContentEditableInput.prototype.showPrimarySelection = function () { + var sel = this.getSelection(), cm = this.cm, prim = cm.doc.sel.primary(); + var from = prim.from(), to = prim.to(); + + if (cm.display.viewTo == cm.display.viewFrom || from.line >= cm.display.viewTo || to.line < cm.display.viewFrom) { + sel.removeAllRanges(); + return + } + + var curAnchor = domToPos(cm, sel.anchorNode, sel.anchorOffset); + var curFocus = domToPos(cm, sel.focusNode, sel.focusOffset); + if (curAnchor && !curAnchor.bad && curFocus && !curFocus.bad && + cmp(minPos(curAnchor, curFocus), from) == 0 && + cmp(maxPos(curAnchor, curFocus), to) == 0) + { return } + + var view = cm.display.view; + var start = (from.line >= cm.display.viewFrom && posToDOM(cm, from)) || + {node: view[0].measure.map[2], offset: 0}; + var end = to.line < cm.display.viewTo && posToDOM(cm, to); + if (!end) { + var measure = view[view.length - 1].measure; + var map = measure.maps ? measure.maps[measure.maps.length - 1] : measure.map; + end = {node: map[map.length - 1], offset: map[map.length - 2] - map[map.length - 3]}; + } + + if (!start || !end) { + sel.removeAllRanges(); + return + } + + var old = sel.rangeCount && sel.getRangeAt(0), rng; + try { rng = range(start.node, start.offset, end.offset, end.node); } + catch(e) {} // Our model of the DOM might be outdated, in which case the range we try to set can be impossible + if (rng) { + if (!gecko && cm.state.focused) { + sel.collapse(start.node, start.offset); + if (!rng.collapsed) { + sel.removeAllRanges(); + sel.addRange(rng); + } + } else { + sel.removeAllRanges(); + sel.addRange(rng); + } + if (old && sel.anchorNode == null) { sel.addRange(old); } + else if (gecko) { this.startGracePeriod(); } + } + this.rememberSelection(); + }; + + ContentEditableInput.prototype.startGracePeriod = function () { + var this$1 = this; + + clearTimeout(this.gracePeriod); + this.gracePeriod = setTimeout(function () { + this$1.gracePeriod = false; + if (this$1.selectionChanged()) + { this$1.cm.operation(function () { return this$1.cm.curOp.selectionChanged = true; }); } + }, 20); + }; + + ContentEditableInput.prototype.showMultipleSelections = function (info) { + removeChildrenAndAdd(this.cm.display.cursorDiv, info.cursors); + removeChildrenAndAdd(this.cm.display.selectionDiv, info.selection); + }; + + ContentEditableInput.prototype.rememberSelection = function () { + var sel = this.getSelection(); + this.lastAnchorNode = sel.anchorNode; this.lastAnchorOffset = sel.anchorOffset; + this.lastFocusNode = sel.focusNode; this.lastFocusOffset = sel.focusOffset; + }; + + ContentEditableInput.prototype.selectionInEditor = function () { + var sel = this.getSelection(); + if (!sel.rangeCount) { return false } + var node = sel.getRangeAt(0).commonAncestorContainer; + return contains(this.div, node) + }; + + ContentEditableInput.prototype.focus = function () { + if (this.cm.options.readOnly != "nocursor") { + if (!this.selectionInEditor() || activeElt(rootNode(this.div)) != this.div) + { this.showSelection(this.prepareSelection(), true); } + this.div.focus(); + } + }; + ContentEditableInput.prototype.blur = function () { this.div.blur(); }; + ContentEditableInput.prototype.getField = function () { return this.div }; + + ContentEditableInput.prototype.supportsTouch = function () { return true }; + + ContentEditableInput.prototype.receivedFocus = function () { + var this$1 = this; + + var input = this; + if (this.selectionInEditor()) + { setTimeout(function () { return this$1.pollSelection(); }, 20); } + else + { runInOp(this.cm, function () { return input.cm.curOp.selectionChanged = true; }); } + + function poll() { + if (input.cm.state.focused) { + input.pollSelection(); + input.polling.set(input.cm.options.pollInterval, poll); + } + } + this.polling.set(this.cm.options.pollInterval, poll); + }; + + ContentEditableInput.prototype.selectionChanged = function () { + var sel = this.getSelection(); + return sel.anchorNode != this.lastAnchorNode || sel.anchorOffset != this.lastAnchorOffset || + sel.focusNode != this.lastFocusNode || sel.focusOffset != this.lastFocusOffset + }; + + ContentEditableInput.prototype.pollSelection = function () { + if (this.readDOMTimeout != null || this.gracePeriod || !this.selectionChanged()) { return } + var sel = this.getSelection(), cm = this.cm; + // On Android Chrome (version 56, at least), backspacing into an + // uneditable block element will put the cursor in that element, + // and then, because it's not editable, hide the virtual keyboard. + // Because Android doesn't allow us to actually detect backspace + // presses in a sane way, this code checks for when that happens + // and simulates a backspace press in this case. + if (android && chrome && this.cm.display.gutterSpecs.length && isInGutter(sel.anchorNode)) { + this.cm.triggerOnKeyDown({type: "keydown", keyCode: 8, preventDefault: Math.abs}); + this.blur(); + this.focus(); + return + } + if (this.composing) { return } + this.rememberSelection(); + var anchor = domToPos(cm, sel.anchorNode, sel.anchorOffset); + var head = domToPos(cm, sel.focusNode, sel.focusOffset); + if (anchor && head) { runInOp(cm, function () { + setSelection(cm.doc, simpleSelection(anchor, head), sel_dontScroll); + if (anchor.bad || head.bad) { cm.curOp.selectionChanged = true; } + }); } + }; + + ContentEditableInput.prototype.pollContent = function () { + if (this.readDOMTimeout != null) { + clearTimeout(this.readDOMTimeout); + this.readDOMTimeout = null; + } + + var cm = this.cm, display = cm.display, sel = cm.doc.sel.primary(); + var from = sel.from(), to = sel.to(); + if (from.ch == 0 && from.line > cm.firstLine()) + { from = Pos(from.line - 1, getLine(cm.doc, from.line - 1).length); } + if (to.ch == getLine(cm.doc, to.line).text.length && to.line < cm.lastLine()) + { to = Pos(to.line + 1, 0); } + if (from.line < display.viewFrom || to.line > display.viewTo - 1) { return false } + + var fromIndex, fromLine, fromNode; + if (from.line == display.viewFrom || (fromIndex = findViewIndex(cm, from.line)) == 0) { + fromLine = lineNo(display.view[0].line); + fromNode = display.view[0].node; + } else { + fromLine = lineNo(display.view[fromIndex].line); + fromNode = display.view[fromIndex - 1].node.nextSibling; + } + var toIndex = findViewIndex(cm, to.line); + var toLine, toNode; + if (toIndex == display.view.length - 1) { + toLine = display.viewTo - 1; + toNode = display.lineDiv.lastChild; + } else { + toLine = lineNo(display.view[toIndex + 1].line) - 1; + toNode = display.view[toIndex + 1].node.previousSibling; + } + + if (!fromNode) { return false } + var newText = cm.doc.splitLines(domTextBetween(cm, fromNode, toNode, fromLine, toLine)); + var oldText = getBetween(cm.doc, Pos(fromLine, 0), Pos(toLine, getLine(cm.doc, toLine).text.length)); + while (newText.length > 1 && oldText.length > 1) { + if (lst(newText) == lst(oldText)) { newText.pop(); oldText.pop(); toLine--; } + else if (newText[0] == oldText[0]) { newText.shift(); oldText.shift(); fromLine++; } + else { break } + } + + var cutFront = 0, cutEnd = 0; + var newTop = newText[0], oldTop = oldText[0], maxCutFront = Math.min(newTop.length, oldTop.length); + while (cutFront < maxCutFront && newTop.charCodeAt(cutFront) == oldTop.charCodeAt(cutFront)) + { ++cutFront; } + var newBot = lst(newText), oldBot = lst(oldText); + var maxCutEnd = Math.min(newBot.length - (newText.length == 1 ? cutFront : 0), + oldBot.length - (oldText.length == 1 ? cutFront : 0)); + while (cutEnd < maxCutEnd && + newBot.charCodeAt(newBot.length - cutEnd - 1) == oldBot.charCodeAt(oldBot.length - cutEnd - 1)) + { ++cutEnd; } + // Try to move start of change to start of selection if ambiguous + if (newText.length == 1 && oldText.length == 1 && fromLine == from.line) { + while (cutFront && cutFront > from.ch && + newBot.charCodeAt(newBot.length - cutEnd - 1) == oldBot.charCodeAt(oldBot.length - cutEnd - 1)) { + cutFront--; + cutEnd++; + } + } + + newText[newText.length - 1] = newBot.slice(0, newBot.length - cutEnd).replace(/^\u200b+/, ""); + newText[0] = newText[0].slice(cutFront).replace(/\u200b+$/, ""); + + var chFrom = Pos(fromLine, cutFront); + var chTo = Pos(toLine, oldText.length ? lst(oldText).length - cutEnd : 0); + if (newText.length > 1 || newText[0] || cmp(chFrom, chTo)) { + replaceRange(cm.doc, newText, chFrom, chTo, "+input"); + return true + } + }; + + ContentEditableInput.prototype.ensurePolled = function () { + this.forceCompositionEnd(); + }; + ContentEditableInput.prototype.reset = function () { + this.forceCompositionEnd(); + }; + ContentEditableInput.prototype.forceCompositionEnd = function () { + if (!this.composing) { return } + clearTimeout(this.readDOMTimeout); + this.composing = null; + this.updateFromDOM(); + this.div.blur(); + this.div.focus(); + }; + ContentEditableInput.prototype.readFromDOMSoon = function () { + var this$1 = this; + + if (this.readDOMTimeout != null) { return } + this.readDOMTimeout = setTimeout(function () { + this$1.readDOMTimeout = null; + if (this$1.composing) { + if (this$1.composing.done) { this$1.composing = null; } + else { return } + } + this$1.updateFromDOM(); + }, 80); + }; + + ContentEditableInput.prototype.updateFromDOM = function () { + var this$1 = this; + + if (this.cm.isReadOnly() || !this.pollContent()) + { runInOp(this.cm, function () { return regChange(this$1.cm); }); } + }; + + ContentEditableInput.prototype.setUneditable = function (node) { + node.contentEditable = "false"; + }; + + ContentEditableInput.prototype.onKeyPress = function (e) { + if (e.charCode == 0 || this.composing) { return } + e.preventDefault(); + if (!this.cm.isReadOnly()) + { operation(this.cm, applyTextInput)(this.cm, String.fromCharCode(e.charCode == null ? e.keyCode : e.charCode), 0); } + }; + + ContentEditableInput.prototype.readOnlyChanged = function (val) { + this.div.contentEditable = String(val != "nocursor"); + }; + + ContentEditableInput.prototype.onContextMenu = function () {}; + ContentEditableInput.prototype.resetPosition = function () {}; + + ContentEditableInput.prototype.needsContentAttribute = true; + + function posToDOM(cm, pos) { + var view = findViewForLine(cm, pos.line); + if (!view || view.hidden) { return null } + var line = getLine(cm.doc, pos.line); + var info = mapFromLineView(view, line, pos.line); + + var order = getOrder(line, cm.doc.direction), side = "left"; + if (order) { + var partPos = getBidiPartAt(order, pos.ch); + side = partPos % 2 ? "right" : "left"; + } + var result = nodeAndOffsetInLineMap(info.map, pos.ch, side); + result.offset = result.collapse == "right" ? result.end : result.start; + return result + } + + function isInGutter(node) { + for (var scan = node; scan; scan = scan.parentNode) + { if (/CodeMirror-gutter-wrapper/.test(scan.className)) { return true } } + return false + } + + function badPos(pos, bad) { if (bad) { pos.bad = true; } return pos } + + function domTextBetween(cm, from, to, fromLine, toLine) { + var text = "", closing = false, lineSep = cm.doc.lineSeparator(), extraLinebreak = false; + function recognizeMarker(id) { return function (marker) { return marker.id == id; } } + function close() { + if (closing) { + text += lineSep; + if (extraLinebreak) { text += lineSep; } + closing = extraLinebreak = false; + } + } + function addText(str) { + if (str) { + close(); + text += str; + } + } + function walk(node) { + if (node.nodeType == 1) { + var cmText = node.getAttribute("cm-text"); + if (cmText) { + addText(cmText); + return + } + var markerID = node.getAttribute("cm-marker"), range; + if (markerID) { + var found = cm.findMarks(Pos(fromLine, 0), Pos(toLine + 1, 0), recognizeMarker(+markerID)); + if (found.length && (range = found[0].find(0))) + { addText(getBetween(cm.doc, range.from, range.to).join(lineSep)); } + return + } + if (node.getAttribute("contenteditable") == "false") { return } + var isBlock = /^(pre|div|p|li|table|br)$/i.test(node.nodeName); + if (!/^br$/i.test(node.nodeName) && node.textContent.length == 0) { return } + + if (isBlock) { close(); } + for (var i = 0; i < node.childNodes.length; i++) + { walk(node.childNodes[i]); } + + if (/^(pre|p)$/i.test(node.nodeName)) { extraLinebreak = true; } + if (isBlock) { closing = true; } + } else if (node.nodeType == 3) { + addText(node.nodeValue.replace(/\u200b/g, "").replace(/\u00a0/g, " ")); + } + } + for (;;) { + walk(from); + if (from == to) { break } + from = from.nextSibling; + extraLinebreak = false; + } + return text + } + + function domToPos(cm, node, offset) { + var lineNode; + if (node == cm.display.lineDiv) { + lineNode = cm.display.lineDiv.childNodes[offset]; + if (!lineNode) { return badPos(cm.clipPos(Pos(cm.display.viewTo - 1)), true) } + node = null; offset = 0; + } else { + for (lineNode = node;; lineNode = lineNode.parentNode) { + if (!lineNode || lineNode == cm.display.lineDiv) { return null } + if (lineNode.parentNode && lineNode.parentNode == cm.display.lineDiv) { break } + } + } + for (var i = 0; i < cm.display.view.length; i++) { + var lineView = cm.display.view[i]; + if (lineView.node == lineNode) + { return locateNodeInLineView(lineView, node, offset) } + } + } + + function locateNodeInLineView(lineView, node, offset) { + var wrapper = lineView.text.firstChild, bad = false; + if (!node || !contains(wrapper, node)) { return badPos(Pos(lineNo(lineView.line), 0), true) } + if (node == wrapper) { + bad = true; + node = wrapper.childNodes[offset]; + offset = 0; + if (!node) { + var line = lineView.rest ? lst(lineView.rest) : lineView.line; + return badPos(Pos(lineNo(line), line.text.length), bad) + } + } + + var textNode = node.nodeType == 3 ? node : null, topNode = node; + if (!textNode && node.childNodes.length == 1 && node.firstChild.nodeType == 3) { + textNode = node.firstChild; + if (offset) { offset = textNode.nodeValue.length; } + } + while (topNode.parentNode != wrapper) { topNode = topNode.parentNode; } + var measure = lineView.measure, maps = measure.maps; + + function find(textNode, topNode, offset) { + for (var i = -1; i < (maps ? maps.length : 0); i++) { + var map = i < 0 ? measure.map : maps[i]; + for (var j = 0; j < map.length; j += 3) { + var curNode = map[j + 2]; + if (curNode == textNode || curNode == topNode) { + var line = lineNo(i < 0 ? lineView.line : lineView.rest[i]); + var ch = map[j] + offset; + if (offset < 0 || curNode != textNode) { ch = map[j + (offset ? 1 : 0)]; } + return Pos(line, ch) + } + } + } + } + var found = find(textNode, topNode, offset); + if (found) { return badPos(found, bad) } + + // FIXME this is all really shaky. might handle the few cases it needs to handle, but likely to cause problems + for (var after = topNode.nextSibling, dist = textNode ? textNode.nodeValue.length - offset : 0; after; after = after.nextSibling) { + found = find(after, after.firstChild, 0); + if (found) + { return badPos(Pos(found.line, found.ch - dist), bad) } + else + { dist += after.textContent.length; } + } + for (var before = topNode.previousSibling, dist$1 = offset; before; before = before.previousSibling) { + found = find(before, before.firstChild, -1); + if (found) + { return badPos(Pos(found.line, found.ch + dist$1), bad) } + else + { dist$1 += before.textContent.length; } + } + } + + // TEXTAREA INPUT STYLE + + var TextareaInput = function(cm) { + this.cm = cm; + // See input.poll and input.reset + this.prevInput = ""; + + // Flag that indicates whether we expect input to appear real soon + // now (after some event like 'keypress' or 'input') and are + // polling intensively. + this.pollingFast = false; + // Self-resetting timeout for the poller + this.polling = new Delayed(); + // Used to work around IE issue with selection being forgotten when focus moves away from textarea + this.hasSelection = false; + this.composing = null; + this.resetting = false; + }; + + TextareaInput.prototype.init = function (display) { + var this$1 = this; + + var input = this, cm = this.cm; + this.createField(display); + var te = this.textarea; + + display.wrapper.insertBefore(this.wrapper, display.wrapper.firstChild); + + // Needed to hide big blue blinking cursor on Mobile Safari (doesn't seem to work in iOS 8 anymore) + if (ios) { te.style.width = "0px"; } + + on(te, "input", function () { + if (ie && ie_version >= 9 && this$1.hasSelection) { this$1.hasSelection = null; } + input.poll(); + }); + + on(te, "paste", function (e) { + if (signalDOMEvent(cm, e) || handlePaste(e, cm)) { return } + + cm.state.pasteIncoming = +new Date; + input.fastPoll(); + }); + + function prepareCopyCut(e) { + if (signalDOMEvent(cm, e)) { return } + if (cm.somethingSelected()) { + setLastCopied({lineWise: false, text: cm.getSelections()}); + } else if (!cm.options.lineWiseCopyCut) { + return + } else { + var ranges = copyableRanges(cm); + setLastCopied({lineWise: true, text: ranges.text}); + if (e.type == "cut") { + cm.setSelections(ranges.ranges, null, sel_dontScroll); + } else { + input.prevInput = ""; + te.value = ranges.text.join("\n"); + selectInput(te); + } + } + if (e.type == "cut") { cm.state.cutIncoming = +new Date; } + } + on(te, "cut", prepareCopyCut); + on(te, "copy", prepareCopyCut); + + on(display.scroller, "paste", function (e) { + if (eventInWidget(display, e) || signalDOMEvent(cm, e)) { return } + if (!te.dispatchEvent) { + cm.state.pasteIncoming = +new Date; + input.focus(); + return + } + + // Pass the `paste` event to the textarea so it's handled by its event listener. + var event = new Event("paste"); + event.clipboardData = e.clipboardData; + te.dispatchEvent(event); + }); + + // Prevent normal selection in the editor (we handle our own) + on(display.lineSpace, "selectstart", function (e) { + if (!eventInWidget(display, e)) { e_preventDefault(e); } + }); + + on(te, "compositionstart", function () { + var start = cm.getCursor("from"); + if (input.composing) { input.composing.range.clear(); } + input.composing = { + start: start, + range: cm.markText(start, cm.getCursor("to"), {className: "CodeMirror-composing"}) + }; + }); + on(te, "compositionend", function () { + if (input.composing) { + input.poll(); + input.composing.range.clear(); + input.composing = null; + } + }); + }; + + TextareaInput.prototype.createField = function (_display) { + // Wraps and hides input textarea + this.wrapper = hiddenTextarea(); + // The semihidden textarea that is focused when the editor is + // focused, and receives input. + this.textarea = this.wrapper.firstChild; + var opts = this.cm.options; + disableBrowserMagic(this.textarea, opts.spellcheck, opts.autocorrect, opts.autocapitalize); + }; + + TextareaInput.prototype.screenReaderLabelChanged = function (label) { + // Label for screenreaders, accessibility + if(label) { + this.textarea.setAttribute('aria-label', label); + } else { + this.textarea.removeAttribute('aria-label'); + } + }; + + TextareaInput.prototype.prepareSelection = function () { + // Redraw the selection and/or cursor + var cm = this.cm, display = cm.display, doc = cm.doc; + var result = prepareSelection(cm); + + // Move the hidden textarea near the cursor to prevent scrolling artifacts + if (cm.options.moveInputWithCursor) { + var headPos = cursorCoords(cm, doc.sel.primary().head, "div"); + var wrapOff = display.wrapper.getBoundingClientRect(), lineOff = display.lineDiv.getBoundingClientRect(); + result.teTop = Math.max(0, Math.min(display.wrapper.clientHeight - 10, + headPos.top + lineOff.top - wrapOff.top)); + result.teLeft = Math.max(0, Math.min(display.wrapper.clientWidth - 10, + headPos.left + lineOff.left - wrapOff.left)); + } + + return result + }; + + TextareaInput.prototype.showSelection = function (drawn) { + var cm = this.cm, display = cm.display; + removeChildrenAndAdd(display.cursorDiv, drawn.cursors); + removeChildrenAndAdd(display.selectionDiv, drawn.selection); + if (drawn.teTop != null) { + this.wrapper.style.top = drawn.teTop + "px"; + this.wrapper.style.left = drawn.teLeft + "px"; + } + }; + + // Reset the input to correspond to the selection (or to be empty, + // when not typing and nothing is selected) + TextareaInput.prototype.reset = function (typing) { + if (this.contextMenuPending || this.composing && typing) { return } + var cm = this.cm; + this.resetting = true; + if (cm.somethingSelected()) { + this.prevInput = ""; + var content = cm.getSelection(); + this.textarea.value = content; + if (cm.state.focused) { selectInput(this.textarea); } + if (ie && ie_version >= 9) { this.hasSelection = content; } + } else if (!typing) { + this.prevInput = this.textarea.value = ""; + if (ie && ie_version >= 9) { this.hasSelection = null; } + } + this.resetting = false; + }; + + TextareaInput.prototype.getField = function () { return this.textarea }; + + TextareaInput.prototype.supportsTouch = function () { return false }; + + TextareaInput.prototype.focus = function () { + if (this.cm.options.readOnly != "nocursor" && (!mobile || activeElt(rootNode(this.textarea)) != this.textarea)) { + try { this.textarea.focus(); } + catch (e) {} // IE8 will throw if the textarea is display: none or not in DOM + } + }; + + TextareaInput.prototype.blur = function () { this.textarea.blur(); }; + + TextareaInput.prototype.resetPosition = function () { + this.wrapper.style.top = this.wrapper.style.left = 0; + }; + + TextareaInput.prototype.receivedFocus = function () { this.slowPoll(); }; + + // Poll for input changes, using the normal rate of polling. This + // runs as long as the editor is focused. + TextareaInput.prototype.slowPoll = function () { + var this$1 = this; + + if (this.pollingFast) { return } + this.polling.set(this.cm.options.pollInterval, function () { + this$1.poll(); + if (this$1.cm.state.focused) { this$1.slowPoll(); } + }); + }; + + // When an event has just come in that is likely to add or change + // something in the input textarea, we poll faster, to ensure that + // the change appears on the screen quickly. + TextareaInput.prototype.fastPoll = function () { + var missed = false, input = this; + input.pollingFast = true; + function p() { + var changed = input.poll(); + if (!changed && !missed) {missed = true; input.polling.set(60, p);} + else {input.pollingFast = false; input.slowPoll();} + } + input.polling.set(20, p); + }; + + // Read input from the textarea, and update the document to match. + // When something is selected, it is present in the textarea, and + // selected (unless it is huge, in which case a placeholder is + // used). When nothing is selected, the cursor sits after previously + // seen text (can be empty), which is stored in prevInput (we must + // not reset the textarea when typing, because that breaks IME). + TextareaInput.prototype.poll = function () { + var this$1 = this; + + var cm = this.cm, input = this.textarea, prevInput = this.prevInput; + // Since this is called a *lot*, try to bail out as cheaply as + // possible when it is clear that nothing happened. hasSelection + // will be the case when there is a lot of text in the textarea, + // in which case reading its value would be expensive. + if (this.contextMenuPending || this.resetting || !cm.state.focused || + (hasSelection(input) && !prevInput && !this.composing) || + cm.isReadOnly() || cm.options.disableInput || cm.state.keySeq) + { return false } + + var text = input.value; + // If nothing changed, bail. + if (text == prevInput && !cm.somethingSelected()) { return false } + // Work around nonsensical selection resetting in IE9/10, and + // inexplicable appearance of private area unicode characters on + // some key combos in Mac (#2689). + if (ie && ie_version >= 9 && this.hasSelection === text || + mac && /[\uf700-\uf7ff]/.test(text)) { + cm.display.input.reset(); + return false + } + + if (cm.doc.sel == cm.display.selForContextMenu) { + var first = text.charCodeAt(0); + if (first == 0x200b && !prevInput) { prevInput = "\u200b"; } + if (first == 0x21da) { this.reset(); return this.cm.execCommand("undo") } + } + // Find the part of the input that is actually new + var same = 0, l = Math.min(prevInput.length, text.length); + while (same < l && prevInput.charCodeAt(same) == text.charCodeAt(same)) { ++same; } + + runInOp(cm, function () { + applyTextInput(cm, text.slice(same), prevInput.length - same, + null, this$1.composing ? "*compose" : null); + + // Don't leave long text in the textarea, since it makes further polling slow + if (text.length > 1000 || text.indexOf("\n") > -1) { input.value = this$1.prevInput = ""; } + else { this$1.prevInput = text; } + + if (this$1.composing) { + this$1.composing.range.clear(); + this$1.composing.range = cm.markText(this$1.composing.start, cm.getCursor("to"), + {className: "CodeMirror-composing"}); + } + }); + return true + }; + + TextareaInput.prototype.ensurePolled = function () { + if (this.pollingFast && this.poll()) { this.pollingFast = false; } + }; + + TextareaInput.prototype.onKeyPress = function () { + if (ie && ie_version >= 9) { this.hasSelection = null; } + this.fastPoll(); + }; + + TextareaInput.prototype.onContextMenu = function (e) { + var input = this, cm = input.cm, display = cm.display, te = input.textarea; + if (input.contextMenuPending) { input.contextMenuPending(); } + var pos = posFromMouse(cm, e), scrollPos = display.scroller.scrollTop; + if (!pos || presto) { return } // Opera is difficult. + + // Reset the current text selection only if the click is done outside of the selection + // and 'resetSelectionOnContextMenu' option is true. + var reset = cm.options.resetSelectionOnContextMenu; + if (reset && cm.doc.sel.contains(pos) == -1) + { operation(cm, setSelection)(cm.doc, simpleSelection(pos), sel_dontScroll); } + + var oldCSS = te.style.cssText, oldWrapperCSS = input.wrapper.style.cssText; + var wrapperBox = input.wrapper.offsetParent.getBoundingClientRect(); + input.wrapper.style.cssText = "position: static"; + te.style.cssText = "position: absolute; width: 30px; height: 30px;\n top: " + (e.clientY - wrapperBox.top - 5) + "px; left: " + (e.clientX - wrapperBox.left - 5) + "px;\n z-index: 1000; background: " + (ie ? "rgba(255, 255, 255, .05)" : "transparent") + ";\n outline: none; border-width: 0; outline: none; overflow: hidden; opacity: .05; filter: alpha(opacity=5);"; + var oldScrollY; + if (webkit) { oldScrollY = te.ownerDocument.defaultView.scrollY; } // Work around Chrome issue (#2712) + display.input.focus(); + if (webkit) { te.ownerDocument.defaultView.scrollTo(null, oldScrollY); } + display.input.reset(); + // Adds "Select all" to context menu in FF + if (!cm.somethingSelected()) { te.value = input.prevInput = " "; } + input.contextMenuPending = rehide; + display.selForContextMenu = cm.doc.sel; + clearTimeout(display.detectingSelectAll); + + // Select-all will be greyed out if there's nothing to select, so + // this adds a zero-width space so that we can later check whether + // it got selected. + function prepareSelectAllHack() { + if (te.selectionStart != null) { + var selected = cm.somethingSelected(); + var extval = "\u200b" + (selected ? te.value : ""); + te.value = "\u21da"; // Used to catch context-menu undo + te.value = extval; + input.prevInput = selected ? "" : "\u200b"; + te.selectionStart = 1; te.selectionEnd = extval.length; + // Re-set this, in case some other handler touched the + // selection in the meantime. + display.selForContextMenu = cm.doc.sel; + } + } + function rehide() { + if (input.contextMenuPending != rehide) { return } + input.contextMenuPending = false; + input.wrapper.style.cssText = oldWrapperCSS; + te.style.cssText = oldCSS; + if (ie && ie_version < 9) { display.scrollbars.setScrollTop(display.scroller.scrollTop = scrollPos); } + + // Try to detect the user choosing select-all + if (te.selectionStart != null) { + if (!ie || (ie && ie_version < 9)) { prepareSelectAllHack(); } + var i = 0, poll = function () { + if (display.selForContextMenu == cm.doc.sel && te.selectionStart == 0 && + te.selectionEnd > 0 && input.prevInput == "\u200b") { + operation(cm, selectAll)(cm); + } else if (i++ < 10) { + display.detectingSelectAll = setTimeout(poll, 500); + } else { + display.selForContextMenu = null; + display.input.reset(); + } + }; + display.detectingSelectAll = setTimeout(poll, 200); + } + } + + if (ie && ie_version >= 9) { prepareSelectAllHack(); } + if (captureRightClick) { + e_stop(e); + var mouseup = function () { + off(window, "mouseup", mouseup); + setTimeout(rehide, 20); + }; + on(window, "mouseup", mouseup); + } else { + setTimeout(rehide, 50); + } + }; + + TextareaInput.prototype.readOnlyChanged = function (val) { + if (!val) { this.reset(); } + this.textarea.disabled = val == "nocursor"; + this.textarea.readOnly = !!val; + }; + + TextareaInput.prototype.setUneditable = function () {}; + + TextareaInput.prototype.needsContentAttribute = false; + + function fromTextArea(textarea, options) { + options = options ? copyObj(options) : {}; + options.value = textarea.value; + if (!options.tabindex && textarea.tabIndex) + { options.tabindex = textarea.tabIndex; } + if (!options.placeholder && textarea.placeholder) + { options.placeholder = textarea.placeholder; } + // Set autofocus to true if this textarea is focused, or if it has + // autofocus and no other element is focused. + if (options.autofocus == null) { + var hasFocus = activeElt(rootNode(textarea)); + options.autofocus = hasFocus == textarea || + textarea.getAttribute("autofocus") != null && hasFocus == document.body; + } + + function save() {textarea.value = cm.getValue();} + + var realSubmit; + if (textarea.form) { + on(textarea.form, "submit", save); + // Deplorable hack to make the submit method do the right thing. + if (!options.leaveSubmitMethodAlone) { + var form = textarea.form; + realSubmit = form.submit; + try { + var wrappedSubmit = form.submit = function () { + save(); + form.submit = realSubmit; + form.submit(); + form.submit = wrappedSubmit; + }; + } catch(e) {} + } + } + + options.finishInit = function (cm) { + cm.save = save; + cm.getTextArea = function () { return textarea; }; + cm.toTextArea = function () { + cm.toTextArea = isNaN; // Prevent this from being ran twice + save(); + textarea.parentNode.removeChild(cm.getWrapperElement()); + textarea.style.display = ""; + if (textarea.form) { + off(textarea.form, "submit", save); + if (!options.leaveSubmitMethodAlone && typeof textarea.form.submit == "function") + { textarea.form.submit = realSubmit; } + } + }; + }; + + textarea.style.display = "none"; + var cm = CodeMirror(function (node) { return textarea.parentNode.insertBefore(node, textarea.nextSibling); }, + options); + return cm + } + + function addLegacyProps(CodeMirror) { + CodeMirror.off = off; + CodeMirror.on = on; + CodeMirror.wheelEventPixels = wheelEventPixels; + CodeMirror.Doc = Doc; + CodeMirror.splitLines = splitLinesAuto; + CodeMirror.countColumn = countColumn; + CodeMirror.findColumn = findColumn; + CodeMirror.isWordChar = isWordCharBasic; + CodeMirror.Pass = Pass; + CodeMirror.signal = signal; + CodeMirror.Line = Line; + CodeMirror.changeEnd = changeEnd; + CodeMirror.scrollbarModel = scrollbarModel; + CodeMirror.Pos = Pos; + CodeMirror.cmpPos = cmp; + CodeMirror.modes = modes; + CodeMirror.mimeModes = mimeModes; + CodeMirror.resolveMode = resolveMode; + CodeMirror.getMode = getMode; + CodeMirror.modeExtensions = modeExtensions; + CodeMirror.extendMode = extendMode; + CodeMirror.copyState = copyState; + CodeMirror.startState = startState; + CodeMirror.innerMode = innerMode; + CodeMirror.commands = commands; + CodeMirror.keyMap = keyMap; + CodeMirror.keyName = keyName; + CodeMirror.isModifierKey = isModifierKey; + CodeMirror.lookupKey = lookupKey; + CodeMirror.normalizeKeyMap = normalizeKeyMap; + CodeMirror.StringStream = StringStream; + CodeMirror.SharedTextMarker = SharedTextMarker; + CodeMirror.TextMarker = TextMarker; + CodeMirror.LineWidget = LineWidget; + CodeMirror.e_preventDefault = e_preventDefault; + CodeMirror.e_stopPropagation = e_stopPropagation; + CodeMirror.e_stop = e_stop; + CodeMirror.addClass = addClass; + CodeMirror.contains = contains; + CodeMirror.rmClass = rmClass; + CodeMirror.keyNames = keyNames; + } + + // EDITOR CONSTRUCTOR + + defineOptions(CodeMirror); + + addEditorMethods(CodeMirror); + + // Set up methods on CodeMirror's prototype to redirect to the editor's document. + var dontDelegate = "iter insert remove copy getEditor constructor".split(" "); + for (var prop in Doc.prototype) { if (Doc.prototype.hasOwnProperty(prop) && indexOf(dontDelegate, prop) < 0) + { CodeMirror.prototype[prop] = (function(method) { + return function() {return method.apply(this.doc, arguments)} + })(Doc.prototype[prop]); } } + + eventMixin(Doc); + CodeMirror.inputStyles = {"textarea": TextareaInput, "contenteditable": ContentEditableInput}; + + // Extra arguments are stored as the mode's dependencies, which is + // used by (legacy) mechanisms like loadmode.js to automatically + // load a mode. (Preferred mechanism is the require/define calls.) + CodeMirror.defineMode = function(name/*, mode, …*/) { + if (!CodeMirror.defaults.mode && name != "null") { CodeMirror.defaults.mode = name; } + defineMode.apply(this, arguments); + }; + + CodeMirror.defineMIME = defineMIME; + + // Minimal default mode. + CodeMirror.defineMode("null", function () { return ({token: function (stream) { return stream.skipToEnd(); }}); }); + CodeMirror.defineMIME("text/plain", "null"); + + // EXTENSIONS + + CodeMirror.defineExtension = function (name, func) { + CodeMirror.prototype[name] = func; + }; + CodeMirror.defineDocExtension = function (name, func) { + Doc.prototype[name] = func; + }; + + CodeMirror.fromTextArea = fromTextArea; + + addLegacyProps(CodeMirror); + + CodeMirror.version = "5.65.19"; + + return CodeMirror; + +}))); +window.CodeMirror = CodeMirror; diff --git a/waterfox/browser/components/sidebar/extlib/diff.js b/waterfox/browser/components/sidebar/extlib/diff.js new file mode 100644 index 000000000000..b77bb6e2cb39 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/diff.js @@ -0,0 +1,774 @@ +/** + * Mainly ported from difflib.py, the standard diff library of Python. + * This code is distributed under the Python Software Foundation License. + * Contributor(s): Sutou Kouhei (porting) + * YUKI "Piro" Hiroshi (encoded diff) + * ------------------------------------------------------------------------ + * Copyright (c) 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009 + * Python Software Foundation. + * All rights reserved. + * + * Copyright (c) 2000 BeOpen.com. + * All rights reserved. + * + * Copyright (c) 1995-2001 Corporation for National Research Initiatives. + * All rights reserved. + * + * Copyright (c) 1991-1995 Stichting Mathematisch Centrum. + * All rights reserved. + */ + +export class SequenceMatcher { + constructor(from, to, junkPredicate) { + this.from = from; + this.to = to; + this.junkPredicate = junkPredicate; + this._updateToIndexes(); + } + + longestMatch(fromStart, fromEnd, toStart, toEnd) { + let bestInfo = this._findBestMatchPosition(fromStart, fromEnd, + toStart, toEnd); + const haveJunk = Object.keys(this.junks).length > 0; + if (haveJunk) { + const adjust = this._adjustBestInfoWithJunkPredicate; + const args = [fromStart, fromEnd, toStart, toEnd]; + + bestInfo = adjust.apply(this, [false, bestInfo].concat(args)); + bestInfo = adjust.apply(this, [true, bestInfo].concat(args)); + } + + return bestInfo; + } + + matches() { + if (!this._matches) + this._matches = this._computeMatches(); + return this._matches; + } + + blocks() { + if (!this._blocks) + this._blocks = this._computeBlocks(); + return this._blocks; + } + + operations() { + if (!this._operations) + this._operations = this._computeOperations(); + return this._operations; + } + + groupedOperations(contextSize) { + if (!contextSize) + contextSize = 3; + + let operations = this.operations(); + if (operations.length == 0) + operations = [['equal', 0, 0, 0, 0]]; + operations = this._expandEdgeEqualOperations(operations, contextSize); + + const groupWindow = contextSize * 2; + const groups = []; + let group = []; + for (const operation of operations) { + const tag = operation[0]; + let fromStart = operation[1]; + const fromEnd = operation[2]; + let toStart = operation[3]; + const toEnd = operation[4]; + + if (tag == 'equal' && fromEnd - fromStart > groupWindow) { + group.push([tag, + fromStart, + Math.min(fromEnd, fromStart + contextSize), + toStart, + Math.min(toEnd, toStart + contextSize)]); + groups.push(group); + group = []; + fromStart = Math.max(fromStart, fromEnd - contextSize); + toStart = Math.max(toStart, toEnd - contextSize); + } + group.push([tag, fromStart, fromEnd, toStart, toEnd]); + } + + if (group.length > 0) + groups.push(group); + + return groups; + } + + ratio() { + if (!this._ratio) + this._ratio = this._computeRatio(); + return this._ratio; + } + + _updateToIndexes() { + this.toIndexes = {}; + this.junks = {}; + + for (let i = 0, length = this.to.length; i < length; i++) { + const item = this.to[i]; + + if (!this.toIndexes[item]) + this.toIndexes[item] = []; + this.toIndexes[item].push(i); + } + + if (!this.junkPredicate) + return; + + const toIndexesWithoutJunk = {}; + for (const item in this.toIndexes) { + if (this.junkPredicate(item)) { + this.junks[item] = true; + } else { + toIndexesWithoutJunk[item] = this.toIndexes[item]; + } + } + this.toIndexes = toIndexesWithoutJunk; + } + + _findBestMatchPosition(fromStart, fromEnd, toStart, toEnd) { + let bestFrom = fromStart; + let bestTo = toStart; + let bestSize = 0; + let lastSizes = {}; + let fromIndex; + + for (fromIndex = fromStart; fromIndex <= fromEnd; fromIndex++) { + const sizes = {}; + + const toIndexes = this.toIndexes[this.from[fromIndex]] || []; + for (let i = 0, length = toIndexes.length; i < length; i++) { + const toIndex = toIndexes[i]; + + if (toIndex < toStart) + continue; + if (toIndex > toEnd) + break; + + const size = sizes[toIndex] = (lastSizes[toIndex - 1] || 0) + 1; + if (size > bestSize) { + bestFrom = fromIndex - size + 1; + bestTo = toIndex - size + 1; + bestSize = size; + } + } + lastSizes = sizes; + } + + return [bestFrom, bestTo, bestSize]; + } + + _adjustBestInfoWithJunkPredicate(shouldJunk, bestInfo, + fromStart, fromEnd, + toStart, toEnd) { + let [bestFrom, bestTo, bestSize] = bestInfo; + + while (bestFrom > fromStart && + bestTo > toStart && + (shouldJunk ? + this.junks[this.to[bestTo - 1]] : + !this.junks[this.to[bestTo - 1]]) && + this.from[bestFrom - 1] == this.to[bestTo - 1]) { + bestFrom -= 1; + bestTo -= 1; + bestSize += 1; + } + + while (bestFrom + bestSize < fromEnd && + bestTo + bestSize < toEnd && + (shouldJunk ? + this.junks[this.to[bestTo + bestSize]] : + !this.junks[this.to[bestTo + bestSize]]) && + this.from[bestFrom + bestSize] == this.to[bestTo + bestSize]) { + bestSize += 1; + } + + return [bestFrom, bestTo, bestSize]; + } + + _computeMatches() { + const matches = []; + const queue = [[0, this.from.length, 0, this.to.length]]; + + while (queue.length > 0) { + const target = queue.pop(); + const [fromStart, fromEnd, toStart, toEnd] = target; + + const match = this.longestMatch(fromStart, fromEnd - 1, toStart, toEnd - 1); + const matchFromIndex = match[0]; + const matchToIndex = match[1]; + const size = match[2]; + if (size > 0) { + if (fromStart < matchFromIndex && toStart < matchToIndex) + queue.push([fromStart, matchFromIndex, toStart, matchToIndex]); + + matches.push(match); + if (matchFromIndex + size < fromEnd && matchToIndex + size < toEnd) + queue.push([matchFromIndex + size, fromEnd, + matchToIndex + size, toEnd]); + } + } + + matches.sort((matchInfo1, matchInfo2) => { + const fromIndex1 = matchInfo1[0]; + const fromIndex2 = matchInfo2[0]; + return fromIndex1 - fromIndex2; + }); + return matches; + } + + _computeBlocks() { + const blocks = []; + let currentFromIndex = 0; + let currentToIndex = 0; + let currentSize = 0; + + for (const match of this.matches()) { + const [fromIndex, toIndex, size] = match; + + if (currentFromIndex + currentSize == fromIndex && + currentToIndex + currentSize == toIndex) { + currentSize += size; + } else { + if (currentSize > 0) + blocks.push([currentFromIndex, currentToIndex, currentSize]); + currentFromIndex = fromIndex; + currentToIndex = toIndex; + currentSize = size; + } + } + + if (currentSize > 0) + blocks.push([currentFromIndex, currentToIndex, currentSize]); + + blocks.push([this.from.length, this.to.length, 0]); + return blocks; + } + + _computeOperations() { + let fromIndex = 0; + let toIndex = 0; + const operations = []; + + for (const block of this.blocks()) { + const [matchFromIndex, matchToIndex, size] = block; + + const tag = this._determineTag(fromIndex, toIndex, + matchFromIndex, matchToIndex); + if (tag != 'equal') + operations.push([tag, + fromIndex, matchFromIndex, + toIndex, matchToIndex]); + + fromIndex = matchFromIndex + size; + toIndex = matchToIndex + size; + + if (size > 0) + operations.push(['equal', + matchFromIndex, fromIndex, + matchToIndex, toIndex]); + } + + return operations; + } + + _determineTag(fromIndex, toIndex, + matchFromIndex, matchToIndex) { + if (fromIndex < matchFromIndex && toIndex < matchToIndex) { + return 'replace'; + } else if (fromIndex < matchFromIndex) { + return 'delete'; + } else if (toIndex < matchToIndex) { + return 'insert'; + } else { + return 'equal'; + } + } + + _expandEdgeEqualOperations(operations, contextSize) { + const expandedOperations = []; + + for (let index = 0, length = operations.length; index < length; index++) { + const operation = operations[index]; + const [tag, fromStart, fromEnd, toStart, toEnd] = operation; + + if (tag == 'equal' && index == 0) { + expandedOperations.push([tag, + Math.max(fromStart, fromEnd - contextSize), + fromEnd, + Math.max(toStart, toEnd - contextSize), + toEnd]); + } else if (tag == 'equal' && index == length - 1) { + expandedOperations.push([tag, + fromStart, + Math.min(fromEnd, fromStart + contextSize), + toStart, + Math.min(toEnd, toStart + contextSize), + toEnd]); + } else { + expandedOperations.push(operation); + } + } + + return expandedOperations; + } + + _computeRatio() { + const length = this.from.length + this.to.length; + + if (length == 0) + return 1.0; + + let matches = 0; + for (const block of this.blocks()) { + const size = block[2]; + matches += size; + } + return 2.0 * matches / length; + } + +}; + + +export class ReadableDiffer { + constructor(from, to) { + this.from = from; + this.to = to; + } + + diff() { + let lines = []; + const matcher = new SequenceMatcher(this.from, this.to); + + for (const operation of matcher.operations()) { + const [tag, fromStart, fromEnd, toStart, toEnd] = operation; + let target; + + switch (tag) { + case 'replace': + target = this._diffLines(fromStart, fromEnd, toStart, toEnd); + lines = lines.concat(target); + break; + case 'delete': + target = this.from.slice(fromStart, fromEnd); + lines = lines.concat(this._tagDeleted(target)); + break; + case 'insert': + target = this.to.slice(toStart, toEnd); + lines = lines.concat(this._tagInserted(target)); + break; + case 'equal': + target = this.from.slice(fromStart, fromEnd); + lines = lines.concat(this._tagEqual(target)); + break; + default: + throw 'unknown tag: ' + tag; + break; + } + } + + return lines; + } + + encodedDiff() { + let lines = []; + const matcher = new SequenceMatcher(this.from, this.to); + + for (const operation of matcher.operations()) { + const [tag, fromStart, fromEnd, toStart, toEnd] = operation; + let target; + + switch (tag) { + case 'replace': + target = this._diffLines(fromStart, fromEnd, toStart, toEnd, true); + lines = lines.concat(target); + break; + case 'delete': + target = this.from.slice(fromStart, fromEnd); + lines = lines.concat(this._tagDeleted(target, true)); + break; + case 'insert': + target = this.to.slice(toStart, toEnd); + lines = lines.concat(this._tagInserted(target, true)); + break; + case 'equal': + target = this.from.slice(fromStart, fromEnd); + lines = lines.concat(this._tagEqual(target, true)); + break; + default: + throw new Error(`unknown tag: ${tag}`); + break; + } + } + + const blocks = []; + let lastBlock = ''; + let lastLineType = ''; + for (const line of lines) { + const lineType = line.match(/^' : '' )); + lastBlock = ``; + lastLineType = lineType; + } + lastBlock += line; + } + if (lastBlock) + blocks.push(`${lastBlock}`); + + return blocks.join(''); + } + + _tagLine(mark, contents) { + return contents.map(content => `${mark} ${content}`); + } + + _encodedTagLine(encodedClass, contents) { + return contents.map(content => `${this._escapeForEncoded(content)}`); + } + + _escapeForEncoded(string) { + return string + .replace(/&/g, '&') + .replace(//g, '>'); + } + + _tagDeleted(contents, encoded) { + return encoded ? + this._encodedTagLine('deleted', contents) : + this._tagLine('-', contents); + } + + _tagInserted(contents, encoded) { + return encoded ? + this._encodedTagLine('inserted', contents) : + this._tagLine('+', contents); + } + + _tagEqual(contents, encoded) { + return encoded ? + this._encodedTagLine('equal', contents) : + this._tagLine(' ', contents); + } + + _tagDifference(contents, encoded) { + return encoded ? + this._encodedTagLine('difference', contents) : + this._tagLine('?', contents); + } + + _findDiffLineInfo(fromStart, fromEnd, toStart, toEnd) { + let bestRatio = 0.74; + let fromEqualIndex, toEqualIndex; + let fromBestIndex, toBestIndex; + + for (let toIndex = toStart; toIndex < toEnd; toIndex++) { + for (let fromIndex = fromStart; fromIndex < fromEnd; fromIndex++) { + if (this.from[fromIndex] == this.to[toIndex]) { + if (fromEqualIndex === undefined) + fromEqualIndex = fromIndex; + if (toEqualIndex === undefined) + toEqualIndex = toIndex; + continue; + } + + const matcher = new SequenceMatcher(this.from[fromIndex], + this.to[toIndex], + this._isSpaceCharacter); + if (matcher.ratio() > bestRatio) { + bestRatio = matcher.ratio(); + fromBestIndex = fromIndex; + toBestIndex = toIndex; + } + } + } + + return [bestRatio, + fromEqualIndex, toEqualIndex, + fromBestIndex, toBestIndex]; + } + + _diffLines(fromStart, fromEnd, toStart, toEnd, encoded) { + const cutOff = 0.75; + const info = this._findDiffLineInfo(fromStart, fromEnd, toStart, toEnd); + let bestRatio = info[0]; + const fromEqualIndex = info[1]; + const toEqualIndex = info[2]; + let fromBestIndex = info[3]; + let toBestIndex = info[4]; + + if (bestRatio < cutOff) { + if (fromEqualIndex === undefined) { + const taggedFrom = this._tagDeleted(this.from.slice(fromStart, fromEnd), encoded); + const taggedTo = this._tagInserted(this.to.slice(toStart, toEnd), encoded); + if (toEnd - toStart < fromEnd - fromStart) + return taggedTo.concat(taggedFrom); + else + return taggedFrom.concat(taggedTo); + } + + fromBestIndex = fromEqualIndex; + toBestIndex = toEqualIndex; + bestRatio = 1.0; + } + + return [].concat( + this.__diffLines(fromStart, fromBestIndex, + toStart, toBestIndex, + encoded), + (encoded ? + this._diffLineEncoded(this.from[fromBestIndex], + this.to[toBestIndex]) : + this._diffLine(this.from[fromBestIndex], + this.to[toBestIndex]) + ), + this.__diffLines(fromBestIndex + 1, fromEnd, + toBestIndex + 1, toEnd, + encoded) + ); + } + + __diffLines(fromStart, fromEnd, toStart, toEnd, encoded) { + if (fromStart < fromEnd) { + if (toStart < toEnd) { + return this._diffLines(fromStart, fromEnd, toStart, toEnd, encoded); + } else { + return this._tagDeleted(this.from.slice(fromStart, fromEnd), encoded); + } + } else { + return this._tagInserted(this.to.slice(toStart, toEnd), encoded); + } + } + + _diffLineEncoded(fromLine, toLine) { + const fromChars = fromLine.split(''); + const toChars = toLine.split(''); + const matcher = new SequenceMatcher(fromLine, toLine, + this._isSpaceCharacter); + const phrases = []; + for (const operation of matcher.operations()) { + const [tag, fromStart, fromEnd, toStart, toEnd] = operation; + const fromPhrase = fromChars.slice(fromStart, fromEnd).join(''); + const toPhrase = toChars.slice(toStart, toEnd).join(''); + switch (tag) { + case 'replace': + case 'delete': + case 'insert': + case 'equal': + phrases.push({ tag : tag, + from : fromPhrase, + encodedFrom : this._escapeForEncoded(fromPhrase), + to : toPhrase, + encodedTo : this._escapeForEncoded(toPhrase), }); + break; + default: + throw new Error(`unknown tag: ${tag}`); + } + } + + const encodedPhrases = []; + let current; + let replaced = 0; + let inserted = 0; + let deleted = 0; + for (let i = 0, maxi = phrases.length; i < maxi; i++) + { + current = phrases[i]; + switch (current.tag) { + case 'replace': + encodedPhrases.push(''); + encodedPhrases.push(this._encodedTagPhrase('deleted', current.encodedFrom)); + encodedPhrases.push(this._encodedTagPhrase('inserted', current.encodedTo)); + encodedPhrases.push(''); + replaced++; + break; + case 'delete': + encodedPhrases.push(this._encodedTagPhrase('deleted', current.encodedFrom)); + deleted++; + break; + case 'insert': + encodedPhrases.push(this._encodedTagPhrase('inserted', current.encodedTo)); + inserted++; + break; + case 'equal': + // \95ύX\93_\82̊Ԃɋ\B2\82܂ꂽ1\95\B6\8E\9A\82\BE\82\AF\82̖\B3\95ύX\95\94\95\AA\82\BE\82\AF\82͓\C1\95ʈ\B5\82\A2 + if ( + current.from.length == 1 && + (i > 0 && phrases[i-1].tag != 'equal') && + (i < maxi-1 && phrases[i+1].tag != 'equal') + ) { + encodedPhrases.push(''); + encodedPhrases.push(this._encodedTagPhrase('duplicated', current.encodedFrom)); + encodedPhrases.push(this._encodedTagPhrase('duplicated', current.encodedTo)); + encodedPhrases.push(''); + } + else { + encodedPhrases.push(current.encodedFrom); + } + break; + } + } + + const extraClass = (replaced || (deleted && inserted)) ? + ' includes-both-modification' : + '' ; + + return [ + `${encodedPhrases.join('')}` + ]; + } + + _encodedTagPhrase(encodedClass, content) { + return `${content}`; + } + + _diffLine(fromLine, toLine) { + let fromTags = ''; + let toTags = ''; + const matcher = new SequenceMatcher(fromLine, toLine, + this._isSpaceCharacter); + + for (const operation of matcher.operations()) { + const [tag, fromStart, fromEnd, toStart, toEnd] = operation; + + const fromLength = fromEnd - fromStart; + const toLength = toEnd - toStart; + switch (tag) { + case 'replace': + fromTags += this._repeat('^', fromLength); + toTags += this._repeat('^', toLength); + break; + case 'delete': + fromTags += this._repeat('-', fromLength); + break; + case 'insert': + toTags += this._repeat('+', toLength); + break; + case 'equal': + fromTags += this._repeat(' ', fromLength); + toTags += this._repeat(' ', toLength); + break; + default: + throw new Error(`unknown tag: ${tag}`); + break; + } + } + + return this._formatDiffPoint(fromLine, toLine, fromTags, toTags); + } + + _formatDiffPoint(fromLine, toLine, fromTags, toTags) { + let common; + let result; + + common = Math.min(this._nLeadingCharacters(fromLine, '\t'), + this._nLeadingCharacters(toLine, '\t')); + common = Math.min(common, + this._nLeadingCharacters(fromTags.slice(0, common), + ' ')); + fromTags = fromTags.slice(common).replace(/\s*$/, ''); + toTags = toTags.slice(common).replace(/\s*$/, ''); + + result = this._tagDeleted([fromLine]); + if (fromTags.length > 0) { + fromTags = this._repeat('\t', common) + fromTags; + result = result.concat(this._tagDifference([fromTags])); + } + result = result.concat(this._tagInserted([toLine])); + if (toTags.length > 0) { + toTags = this._repeat('\t', common) + toTags; + result = result.concat(this._tagDifference([toTags])); + } + + return result; + } + + _nLeadingCharacters(string, character) { + let n = 0; + while (string[n] == character) { + n++; + } + return n; + } + + _isSpaceCharacter(character) { + return character == ' ' || character == '\t'; + } + + _repeat(string, n) { + let result = ''; + for (; n > 0; n--) { + result += string; + } + return result; + } + +}; + + +export const Diff = { + + readable(from, to, encoded) { + const differ = new ReadableDiffer(this._splitWithLine(from), this._splitWithLine(to)); + return encoded ? + differ.encodedDiff() : + differ.diff(encoded).join('\n') ; + }, + + foldedReadable(from, to, encoded) { + const differ = new ReadableDiffer(this._splitWithLine(this._fold(from)), + this._splitWithLine(this._fold(to))); + return encoded ? + differ.encodedDiff() : + differ.diff(encoded).join('\n') ; + }, + + isInterested(diff) { + if (!diff) + return false; + + if (diff.length == 0) + return false; + + if (!diff.match(/^[-+]/mg)) + return false; + + if (diff.match(/^[ ?]/mg)) + return true; + + if (diff.match(/(?:.*\n){2,}/g)) + return true; + + if (this.needFold(diff)) + return true; + + return false; + }, + + needFold(diff) { + if (!diff) + return false; + + if (diff.match(/^[-+].{79}/mg)) + return true; + + return false; + }, + + _splitWithLine(string) { + string = String(string); + return string.length == 0 ? [] : string.split(/\r?\n/); + }, + + _fold(string) { + string = String(string); + const foldedLines = string.split('\n').map(line => line.replace(/(.{78})/g, '$1\n')); + return foldedLines.join('\n'); + } + +}; diff --git a/waterfox/browser/components/sidebar/extlib/dom-updater.js b/waterfox/browser/components/sidebar/extlib/dom-updater.js new file mode 100644 index 000000000000..8f9c11d3681c --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/dom-updater.js @@ -0,0 +1,145 @@ +/* + license: The MIT License, Copyright (c) 2020 YUKI "Piro" Hiroshi +*/ + +import { SequenceMatcher } from './diff.js'; + +export const DOMUpdater = { + /** + * method + * @param before {Node} - the node to be updated, e.g. Element + * @param after {Node} - the node describing updated state, + * e.g. DocumentFragment + * @return count {number} - the count of appied changes + */ + update(before, after, context) { + let topLevel = false; + if (!context) { + topLevel = true; + context = { + count: 0, + beforeRange: before.ownerDocument.createRange(), + afterRange: after.ownerDocument.createRange() + }; + } + const { beforeRange, afterRange } = context; + + if (before.nodeValue !== null || + after.nodeValue !== null) { + if (before.nodeValue != after.nodeValue) { + //console.log('node value: ', after.nodeValue); + before.nodeValue = after.nodeValue; + context.count++; + } + return context.count; + } + + const beforeNodes = Array.from(before.childNodes, this._getDiffableNodeString); + const afterNodes = Array.from(after.childNodes, this._getDiffableNodeString); + const nodeOerations = (new SequenceMatcher(beforeNodes, afterNodes)).operations(); + // Update from back to front for safety! + for (const operation of nodeOerations.reverse()) { + const [tag, fromStart, fromEnd, toStart, toEnd] = operation; + switch (tag) { + case 'equal': + for (let i = 0, maxi = fromEnd - fromStart; i < maxi; i++) { + this.update( + before.childNodes[fromStart + i], + after.childNodes[toStart + i], + context + ); + } + break; + case 'delete': + beforeRange.setStart(before, fromStart); + beforeRange.setEnd(before, fromEnd); + beforeRange.deleteContents(); + context.count++; + break; + case 'insert': + beforeRange.setStart(before, fromStart); + beforeRange.setEnd(before, fromEnd); + afterRange.setStart(after, toStart); + afterRange.setEnd(after, toEnd); + beforeRange.insertNode(afterRange.cloneContents()); + context.count++; + break; + case 'replace': + beforeRange.setStart(before, fromStart); + beforeRange.setEnd(before, fromEnd); + beforeRange.deleteContents(); + context.count++; + afterRange.setStart(after, toStart); + afterRange.setEnd(after, toEnd); + beforeRange.insertNode(afterRange.cloneContents()); + context.count++; + break; + } + } + if (topLevel) { + beforeRange.detach(); + afterRange.detach(); + } + + if (before.nodeType == before.ELEMENT_NODE && + after.nodeType == after.ELEMENT_NODE) { + const beforeAttrs = Array.from(before.attributes, attr => `${attr.name}:${attr.value}`). sort(); + const afterAttrs = Array.from(after.attributes, attr => `${attr.name}:${attr.value}`). sort(); + const attrOerations = (new SequenceMatcher(beforeAttrs, afterAttrs)).operations(); + for (const operation of attrOerations) { + const [tag, fromStart, fromEnd, toStart, toEnd] = operation; + switch (tag) { + case 'equal': + break; + case 'delete': + for (let i = fromStart; i < fromEnd; i++) { + const name = beforeAttrs[i].split(':')[0]; + //console.log('delete: delete attr: ', name); + before.removeAttribute(name); + context.count++; + } + break; + case 'insert': + for (let i = toStart; i < toEnd; i++) { + const attr = afterAttrs[i].split(':'); + const name = attr[0]; + const value = attr.slice(1).join(':'); + //console.log('insert: set attr: ', name, value); + before.setAttribute(name, value); + context.count++; + } + break; + case 'replace': + const insertedAttrs = new Set(); + for (let i = toStart; i < toEnd; i++) { + const attr = afterAttrs[i].split(':'); + const name = attr[0]; + const value = attr.slice(1).join(':'); + //console.log('replace: set attr: ', name, value); + before.setAttribute(name, value); + insertedAttrs.add(name); + context.count++; + } + for (let i = fromStart; i < fromEnd; i++) { + const name = beforeAttrs[i].split(':')[0]; + if (insertedAttrs.has(name)) + continue; + //console.log('replace: delete attr: ', name); + before.removeAttribute(name); + context.count++; + } + break; + } + } + } + return context.count; + }, + + _getDiffableNodeString(node) { + if (node.nodeType == node.ELEMENT_NODE) + return `element:${node.tagName}#${node.id}#${node.getAttribute('anonid')}`; + else + return `node:${node.nodeType}`; + } + +}; diff --git a/waterfox/browser/components/sidebar/extlib/l10n-classic.js b/waterfox/browser/components/sidebar/extlib/l10n-classic.js new file mode 100644 index 000000000000..142cf595ba0e --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/l10n-classic.js @@ -0,0 +1,57 @@ +/* + license: The MIT License, Copyright (c) 2016-2024 YUKI "Piro" Hiroshi + original: + http://github.com/piroor/webextensions-lib-l10n +*/ + +var l10n = { + updateString(string) { + return string.replace(/__MSG_([-@\.\w]+)__/g, (matched, key) => { + return chrome.i18n.getMessage(key) || matched; + }); + }, + + $log(message, ...args) { + message = `l10s: ${message}`; + if (typeof window.log === 'function') + log(message, ...args); + else + console.log(message, ...args); + }, + + updateSubtree(node) { + const texts = document.evaluate( + 'descendant::text()[contains(self::text(), "__MSG_")]', + node, + null, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, + null + ); + for (let i = 0, maxi = texts.snapshotLength; i < maxi; i++) { + const text = texts.snapshotItem(i); + text.nodeValue = this.updateString(text.nodeValue); + } + + const attributes = document.evaluate( + 'descendant::*/attribute::*[contains(., "__MSG_")]', + node, + null, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, + null + ); + for (let i = 0, maxi = attributes.snapshotLength; i < maxi; i++) { + const attribute = attributes.snapshotItem(i); + this.$log('apply', attribute); + attribute.value = this.updateString(attribute.value); + } + }, + + updateDocument() { + this.updateSubtree(document); + } +}; + +document.addEventListener('DOMContentLoaded', () => { + l10n.updateDocument(); +}, { once: true }); +window.l10n = l10n; diff --git a/waterfox/browser/components/sidebar/extlib/l10n.js b/waterfox/browser/components/sidebar/extlib/l10n.js new file mode 100644 index 000000000000..f4e90c74402d --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/l10n.js @@ -0,0 +1,57 @@ +/* + license: The MIT License, Copyright (c) 2016-2024 YUKI "Piro" Hiroshi + original: + http://github.com/piroor/webextensions-lib-l10n +*/ + +var l10n = { + updateString(string) { + return string.replace(/__MSG_([-@\.\w]+)__/g, (matched, key) => { + return chrome.i18n.getMessage(key) || matched; + }); + }, + + $log(message, ...args) { + message = `l10s: ${message}`; + if (typeof window.log === 'function') + log(message, ...args); + else + console.log(message, ...args); + }, + + updateSubtree(node) { + const texts = document.evaluate( + 'descendant::text()[contains(self::text(), "__MSG_")]', + node, + null, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, + null + ); + for (let i = 0, maxi = texts.snapshotLength; i < maxi; i++) { + const text = texts.snapshotItem(i); + text.nodeValue = this.updateString(text.nodeValue); + } + + const attributes = document.evaluate( + 'descendant::*/attribute::*[contains(., "__MSG_")]', + node, + null, + XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, + null + ); + for (let i = 0, maxi = attributes.snapshotLength; i < maxi; i++) { + const attribute = attributes.snapshotItem(i); + this.$log('apply', attribute); + attribute.value = this.updateString(attribute.value); + } + }, + + updateDocument() { + this.updateSubtree(document); + } +}; + +document.addEventListener('DOMContentLoaded', () => { + l10n.updateDocument(); +}, { once: true }); +export default l10n; diff --git a/waterfox/browser/components/sidebar/extlib/placeholder-parser.js b/waterfox/browser/components/sidebar/extlib/placeholder-parser.js new file mode 100644 index 000000000000..69c4e8e51e21 --- /dev/null +++ b/waterfox/browser/components/sidebar/extlib/placeholder-parser.js @@ -0,0 +1,311 @@ +/* + license: The MIT License, Copyright (c) 2022 YUKI "Piro" Hiroshi +*/ +'use strict'; + +export class PlaceHolderParserError extends Error { + constructor(message, originalError) { + super(message); + this.originalError = originalError; + } +} + +export function process(input, processor, processedInput = '', logger = (() => {})) { + let output = ''; + + let lastToken = ''; + let inPlaceHolder = false; + let inArgsPart = false; + let inSingleQuoteString = false; + let inDoubleQuoteString = false; + let inBackQuoteString = false; + let escaped = false; + + let name = ''; + let args = []; + let rawArgs = ''; + + for (const character of input) { + processedInput += character; + //console.log({input, character, lastToken, inPlaceHolder, inSingleQuoteString, inDoubleQuoteString, inArgsPart, escaped, output, name, rawArgs, args}); + + if (escaped) { + if ((inDoubleQuoteString && character == '"') || + (inSingleQuoteString && character == "'") || + (inBackQuoteString && character == '`')) { + if (inArgsPart) + rawArgs += '\\'; + } + else if ((inDoubleQuoteString && character != '"') || + (inSingleQuoteString && character != "'") || + (inBackQuoteString && character != '`') || + (!inDoubleQuoteString && + !inSingleQuoteString && + !inBackQuoteString && + inArgsPart && + character != ')')) { + if (inArgsPart) + rawArgs += '\\'; + lastToken += '\\'; + } + lastToken += character; + if (inArgsPart) + rawArgs += character; + escaped = false; + continue; + } + + switch (character) { + case '\\': + if (!inPlaceHolder) { + output += character; + lastToken = ''; + continue; + } + + if (!escaped) { + escaped = true; + continue; + } + + if (inArgsPart) + rawArgs += character; + + lastToken += character; + continue; + + case '%': + if (!inPlaceHolder) { + inPlaceHolder = true; + output += lastToken; + lastToken = ''; + continue; + } + + if (inArgsPart) + rawArgs += character; + + if (inSingleQuoteString || + inDoubleQuoteString || + inBackQuoteString || + inArgsPart) { + lastToken += character; + continue; + } + + if (!name) { + if (lastToken != '') + name = lastToken; + else + throw new PlaceHolderParserError(`Missing placeholder name: ${processedInput}`); + } + + inPlaceHolder = false; + try { + logger('parser: placeholder ', { name, rawArgs, args }); + output += processor(name, rawArgs, ...args); + } + catch(error) { + throw new PlaceHolderParserError(`Unhandled error: ${error.message}\n${error.stack}`, error); + } + lastToken = ''; + name = ''; + args = []; + rawArgs = ''; + continue; + + case '(': + if (!inPlaceHolder) { + output += character; + lastToken = ''; + continue; + } + + if (inArgsPart) + rawArgs += character; + else if (rawArgs != '') + rawArgs += ', '; + + if (inSingleQuoteString || + inDoubleQuoteString || + inBackQuoteString || + inArgsPart) { + lastToken += character; + continue; + } + + inArgsPart = true; + if (name == '' && lastToken != '') + name = lastToken; + lastToken = ''; + continue; + + case ')': + if (!inPlaceHolder) { + output += character; + lastToken = ''; + continue; + } + + if (inSingleQuoteString || + inDoubleQuoteString || + inBackQuoteString || + !inArgsPart) { + if (inArgsPart) + rawArgs += character; + lastToken += character; + continue; + } + + inArgsPart = false; + if (rawArgs.trim() != '') { + try { + args.push(process(lastToken, processor, processedInput)); + } + catch(error) { + throw new PlaceHolderParserError(`Unhandled error: ${error.message}\n${error.stack}`, error); + } + } + lastToken = ''; + continue; + + case ',': + if (!inPlaceHolder) { + output += character; + lastToken = ''; + continue; + } + + if (inArgsPart) + rawArgs += character; + + if (inSingleQuoteString || + inDoubleQuoteString || + inBackQuoteString || + !inArgsPart) { + lastToken += character; + continue; + } + + try { + args.push(process(lastToken, processor, processedInput)); + } + catch(error) { + throw new PlaceHolderParserError(`Unhandled error: ${error.message}\n${error.stack}`, error); + } + lastToken = ''; + continue; + + case '"': + if (!inPlaceHolder) { + output += character; + lastToken = ''; + continue; + } + + if (inArgsPart) + rawArgs += character; + + if (inSingleQuoteString || + inBackQuoteString) { + lastToken += character; + continue; + } + + if (inDoubleQuoteString) { + inDoubleQuoteString = false; + continue; + } + + inDoubleQuoteString = true; + continue; + + case "'": + if (!inPlaceHolder) { + output += character; + lastToken = ''; + continue; + } + + if (inArgsPart) + rawArgs += character; + + if (inDoubleQuoteString || + inBackQuoteString) { + lastToken += character; + continue; + } + + if (inSingleQuoteString) { + inSingleQuoteString = false; + continue; + } + + inSingleQuoteString = true; + continue; + + case '`': + if (!inPlaceHolder) { + output += character; + lastToken = ''; + continue; + } + + if (inArgsPart) + rawArgs += character; + + if (inDoubleQuoteString || + inSingleQuoteString) { + lastToken += character; + continue; + } + + if (inBackQuoteString) { + inBackQuoteString = false; + continue; + } + + inBackQuoteString = true; + continue; + + default: + if (!inPlaceHolder) { + output += character; + lastToken = ''; + continue; + } + + if (inArgsPart) + rawArgs += character; + + if (character.trim() == '') { // whitespace + if (inSingleQuoteString || + inDoubleQuoteString || + inBackQuoteString || + !inArgsPart) { + lastToken += character; + } + } + else { + lastToken += character; + } + continue; + } + } + + if (inPlaceHolder) + throw new PlaceHolderParserError(`Unterminated placeholder: ${processedInput}`); + + if (inArgsPart) + throw new PlaceHolderParserError(`Unterminated arguments for the placeholder "${name}": ${processedInput}`); + + if (inSingleQuoteString || + inDoubleQuoteString || + inBackQuoteString) + throw new PlaceHolderParserError(`Unterminated string: ${processedInput}`); + + if (escaped) + output += '\\'; + + return output; +} diff --git a/waterfox/browser/components/sidebar/manifest.json b/waterfox/browser/components/sidebar/manifest.json new file mode 100644 index 000000000000..b622913e07b2 --- /dev/null +++ b/waterfox/browser/components/sidebar/manifest.json @@ -0,0 +1,276 @@ +{ + "manifest_version": 2, + "name": "__MSG_extensionName__", + "version": "1.1.0", + "browser_specific_settings": { + "gecko": { + "id": "sidebar@waterfox.net", + "strict_min_version": "139.0" + } + }, + "author": "Waterfox", + "hidden": true, + "description": "__MSG_extensionDescription__", + "permissions": [ + "activeTab", + "bookmarks", + "clipboardRead", + "contextualIdentities", + "cookies", + "menus", + "menus.overrideContext", + "notifications", + "search", + "sessions", + "storage", + "tabGroups", + "tabs", + "theme", + "" + ], + "background": { + "page": "background/background.html" + }, + "commands": { + "reloadTree": { + "description": "__MSG_context_reloadTree_command__" + }, + "reloadDescendants": { + "description": "__MSG_context_reloadDescendants_command__" + }, + "unblockAutoplayTree": { + "description": "__MSG_context_unblockAutoplayTree_command__" + }, + "unblockAutoplayDescendants": { + "description": "__MSG_context_unblockAutoplayDescendants_command__" + }, + "toggleMuteTree": { + "description": "__MSG_context_toggleMuteTree_command__" + }, + "toggleMuteDescendants": { + "description": "__MSG_context_toggleMuteDescendants_command__" + }, + "closeTree": { + "description": "__MSG_context_closeTree_command__" + }, + "closeDescendants": { + "description": "__MSG_context_closeDescendants_command__" + }, + "closeOthers": { + "description": "__MSG_context_closeOthers_command__" + }, + "toggleSticky": { + "description": "__MSG_context_toggleSticky_command__" + }, + "collapseTree": { + "description": "__MSG_context_collapseTree_command__" + }, + "collapseTreeRecursively": { + "description": "__MSG_context_collapseTreeRecursively_command__" + }, + "collapseAll": { + "description": "__MSG_context_collapseAll_command__" + }, + "expandTree": { + "description": "__MSG_context_expandTree_command__" + }, + "expandTreeRecursively": { + "description": "__MSG_context_expandTreeRecursively_command__" + }, + "expandAll": { + "description": "__MSG_context_expandAll_command__" + }, + "toggleTreeCollapsed": { + "description": "__MSG_command_toggleTreeCollapsed__" + }, + "toggleTreeCollapsedRecursively": { + "description": "__MSG_command_toggleTreeCollapsedRecursively__" + }, + "bookmarkTree": { + "description": "__MSG_context_bookmarkTree_command__" + }, + "newIndependentTab": { + "description": "__MSG_tabbar_newTabButton_tooltip__: __MSG_tabbar_newTabAction_independent_command__" + }, + "newChildTab": { + "description": "__MSG_tabbar_newTabButton_tooltip__: __MSG_tabbar_newTabAction_child_command__" + }, + "newChildTabTop": { + "description": "__MSG_tabbar_newTabButton_tooltip__: __MSG_tabbar_newTabAction_childTop_command__" + }, + "newChildTabEnd": { + "description": "__MSG_tabbar_newTabButton_tooltip__: __MSG_tabbar_newTabAction_childEnd_command__" + }, + "newSiblingTab": { + "description": "__MSG_tabbar_newTabButton_tooltip__: __MSG_tabbar_newTabAction_sibling_command__" + }, + "newNextSiblingTab": { + "description": "__MSG_tabbar_newTabButton_tooltip__: __MSG_tabbar_newTabAction_nextSibling_command__" + }, + "newContainerTab": { + "description": "__MSG_tabbar_newTabWithContexualIdentity_tooltip__" + }, + "tabMoveUp": { + "description": "__MSG_command_tabMoveUp__" + }, + "treeMoveUp": { + "description": "__MSG_command_treeMoveUp__" + }, + "tabMoveDown": { + "description": "__MSG_command_tabMoveDown__" + }, + "treeMoveDown": { + "description": "__MSG_command_treeMoveDown__" + }, + "focusPrevious": { + "description": "__MSG_command_focusPrevious__" + }, + "focusPreviousSilently": { + "description": "__MSG_command_focusPreviousSilently__" + }, + "focusNext": { + "description": "__MSG_command_focusNext__" + }, + "focusNextSilently": { + "description": "__MSG_command_focusNextSilently__" + }, + "focusParent": { + "description": "__MSG_command_focusParent__" + }, + "focusParentOrCollapse": { + "description": "__MSG_command_focusParentOrCollapse__" + }, + "focusFirstChild": { + "description": "__MSG_command_focusFirstChild__" + }, + "focusFirstChildOrExpand": { + "description": "__MSG_command_focusFirstChildOrExpand__" + }, + "focusLastChild": { + "description": "__MSG_command_focusLastChild__" + }, + "focusPreviousSibling": { + "description": "__MSG_command_focusPreviousSibling__" + }, + "focusNextSibling": { + "description": "__MSG_command_focusNextSibling__" + }, + "simulateUpOnTree": { + "description": "__MSG_command_simulateUpOnTree__", + "suggested_key": { + "default": "Alt+Shift+Up", + "mac": "MacCtrl+Shift+Up" + } + }, + "simulateDownOnTree": { + "description": "__MSG_command_simulateDownOnTree__", + "suggested_key": { + "default": "Alt+Shift+Down", + "mac": "MacCtrl+Shift+Down" + } + }, + "simulateLeftOnTree": { + "description": "__MSG_command_simulateLeftOnTree__", + "suggested_key": { + "default": "Alt+Shift+Left", + "mac": "MacCtrl+Shift+Left" + } + }, + "simulateRightOnTree": { + "description": "__MSG_command_simulateRightOnTree__", + "suggested_key": { + "default": "Alt+Shift+Right", + "mac": "MacCtrl+Shift+Right" + } + }, + "tabbarUp": { + "description": "__MSG_command_tabbarUp__", + "suggested_key": { + "default": "Alt+Up" + } + }, + "tabbarPageUp": { + "description": "__MSG_command_tabbarPageUp__", + "suggested_key": { + "default": "Alt+PageUp" + } + }, + "tabbarHome": { + "description": "__MSG_command_tabbarHome__", + "suggested_key": { + "default": "Alt+Shift+Home" + } + }, + "tabbarDown": { + "description": "__MSG_command_tabbarDown__", + "suggested_key": { + "default": "Alt+Down" + } + }, + "tabbarPageDown": { + "description": "__MSG_command_tabbarPageDown__", + "suggested_key": { + "default": "Alt+PageDown" + } + }, + "tabbarEnd": { + "description": "__MSG_command_tabbarEnd__", + "suggested_key": { + "default": "Alt+End" + } + }, + "toggleSubPanel": { + "description": "__MSG_command_toggleSubPanel__", + "suggested_key": { + "default": "F2" + } + }, + "switchSubPanel": { + "description": "__MSG_command_switchSubPanel__" + }, + "increaseSubPanel": { + "description": "__MSG_command_increaseSubPanel__" + }, + "decreaseSubPanel": { + "description": "__MSG_command_decreaseSubPanel__" + } + }, + "web_accessible_resources": [ + "/resources/blank.html", + "/resources/group-tab.html*", + "/resources/icons/*", + "/sidebar/styles/icons/*" + ], + "protocol_handlers": [ + { "protocol": "ext+ws", + "name": "Waterfox", + "uriTemplate": "/resources/protocol-handler.html?%s" } + ], + "experiment_apis": { + "prefs": { + "schema": "experiments/prefs.json", + "parent": { + "scopes": ["addon_parent"], + "paths": [["prefs"]], + "script": "experiments/prefs.js" + } + }, + "syncPrefs": { + "schema": "experiments/syncPrefs.json", + "child": { + "scopes": ["addon_child"], + "paths": [["syncPrefs"]], + "script": "experiments/syncPrefs.js" + } + }, + "waterfoxBridge": { + "schema": "experiments/waterfoxBridge.json", + "parent": { + "scopes": ["addon_parent"], + "paths": [["waterfoxBridge"]], + "script": "experiments/waterfoxBridge.js" + } + } + }, + "default_locale": "en" +} diff --git a/waterfox/browser/components/sidebar/moz.build b/waterfox/browser/components/sidebar/moz.build new file mode 100644 index 000000000000..9a9c62662524 --- /dev/null +++ b/waterfox/browser/components/sidebar/moz.build @@ -0,0 +1,11 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +JAR_MANIFESTS += ["addon-jar.mn"] + +EXTRA_JS_MODULES += [ + "SidebarPreferencesHandler.sys.mjs", +] diff --git a/waterfox/browser/components/sidebar/options/as-child.png b/waterfox/browser/components/sidebar/options/as-child.png new file mode 100644 index 000000000000..81cfd7bf5801 Binary files /dev/null and b/waterfox/browser/components/sidebar/options/as-child.png differ diff --git a/waterfox/browser/components/sidebar/options/as-independent.png b/waterfox/browser/components/sidebar/options/as-independent.png new file mode 100644 index 000000000000..a00d814e0ce1 Binary files /dev/null and b/waterfox/browser/components/sidebar/options/as-independent.png differ diff --git a/waterfox/browser/components/sidebar/options/as-next-sibling.png b/waterfox/browser/components/sidebar/options/as-next-sibling.png new file mode 100644 index 000000000000..59977d8a66bc Binary files /dev/null and b/waterfox/browser/components/sidebar/options/as-next-sibling.png differ diff --git a/waterfox/browser/components/sidebar/options/as-sibling.png b/waterfox/browser/components/sidebar/options/as-sibling.png new file mode 100644 index 000000000000..e0a92ab44fd3 Binary files /dev/null and b/waterfox/browser/components/sidebar/options/as-sibling.png differ diff --git a/waterfox/browser/components/sidebar/options/init-ws.js b/waterfox/browser/components/sidebar/options/init-ws.js new file mode 100644 index 000000000000..eb5a4887fc0f --- /dev/null +++ b/waterfox/browser/components/sidebar/options/init-ws.js @@ -0,0 +1,10 @@ +/* +# 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'; + +import './init.js'; + +import '/common/sync-provider.js'; diff --git a/waterfox/browser/components/sidebar/options/init.js b/waterfox/browser/components/sidebar/options/init.js new file mode 100644 index 000000000000..ee6e19a8ff43 --- /dev/null +++ b/waterfox/browser/components/sidebar/options/init.js @@ -0,0 +1,1075 @@ +/* +# 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'; + +import Options from '/extlib/Options.js'; +import '/extlib/l10n.js'; +import RichConfirm from '/extlib/RichConfirm.js'; + +import { + DEVICE_SPECIFIC_CONFIG_KEYS, + log, + wait, + configs, + sanitizeForHTMLText, + loadUserStyleRules, + saveUserStyleRules, + sanitizeAccesskeyMark, + isRTL, +} from '/common/common.js'; + +import * as Constants from '/common/constants.js'; +import * as Permissions from '/common/permissions.js'; +import * as Bookmark from '/common/bookmark.js'; +import * as BrowserTheme from '/common/browser-theme.js'; +import * as TSTAPI from '/common/tst-api.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Sync from '/common/sync.js'; + +// Waterfox Tabs Sidebar specific +import '/common/sync-provider.js'; + +log.context = 'Options'; + +const options = new Options(configs, { + steps: { + faviconizedTabScale: '0.01' + }, + onImporting(values) { + for (const key of DEVICE_SPECIFIC_CONFIG_KEYS) { + if (JSON.stringify(configs[key]) != JSON.stringify(configs.$default[key])) + values[key] = configs[key]; + else + delete values[key]; + } + return values; + }, + onExporting(values) { + for (const key of DEVICE_SPECIFIC_CONFIG_KEYS) { + delete values[key]; + } + return values; + }, +}); + +document.title = browser.i18n.getMessage('config_title'); +if ((location.hash && + /^#!?$/.test(location.hash)) || + /independent=true/.test(location.search)) + document.body.classList.add('independent'); + +document.documentElement.classList.toggle('rtl', isRTL()); + +const CODEMIRROR_THEMES = ` +3024-day +3024-night +abcdef +ambiance-mobile +ambiance +ayu-dark +ayu-mirage +base16-dark +base16-light +bespin +blackboard +cobalt +colorforth +darcula +dracula +duotone-dark +duotone-light +eclipse +elegant +erlang-dark +gruvbox-dark +hopscotch +icecoder +idea +isotope +lesser-dark +liquibyte +lucario +material-darker +material-ocean +material-palenight +material +mbo +mdn-like +midnight +monokai +moxer +neat +neo +night +nord +oceanic-next +panda-syntax +paraiso-dark +paraiso-light +pastel-on-dark +railscasts +rubyblue +seti +shadowfox +solarized +ssms +the-matrix +tomorrow-night-bright +tomorrow-night-eighties +ttcn +twilight +vibrant-ink +xq-dark +xq-light +yeti +yonce +zenburn +`.trim().split(/\s+/); +{ + document.querySelector('#userStyleRulesFieldTheme').insertAdjacentHTML( + 'beforeend', + CODEMIRROR_THEMES.map(theme => ` + + `.trim()).join('') + ); +} + +const mUserStyleRulesField = document.getElementById('userStyleRulesField'); +let mUserStyleRulesFieldEditor; + +const mDarkModeMedia = window.matchMedia('(prefers-color-scheme: dark)'); + +let mShowExpertOptionsTemporarily = false; + +function onConfigChanged(key) { + const value = configs[key]; + switch (key) { + case 'successorTabControlLevel': { + const checkbox = document.getElementById('simulateSelectOwnerOnClose'); + const label = checkbox.parentNode; + if (value == Constants.kSUCCESSOR_TAB_CONTROL_NEVER) { + checkbox.setAttribute('disabled', true); + label.setAttribute('disabled', true); + } + else { + checkbox.removeAttribute('disabled'); + label.removeAttribute('disabled'); + } + }; break; + + case 'parentTabOperationBehaviorMode': { + const nodes = document.querySelectorAll('#parentTabOperationBehaviorModeGroup > ul > li > :not(label)'); + for (const node of nodes) { + node.style.display = node.parentNode.querySelector('input[type="radio"]').checked ? '' : 'none'; + const chosen = node.querySelector(`[type="radio"][data-config-key="closeParentBehavior"][value="${configs.closeParentBehavior}"]`); + if (chosen) { + chosen.checked = true; + continue; + } + const chooser = node.querySelector('[data-config-key="closeParentBehavior"]'); + if (chooser) + chooser.value = configs.closeParentBehavior; + } + }; break; + + case 'autoAttachOnAnyOtherTrigger': { + const nodes = document.querySelectorAll('.sub.autoAttachOnAnyOtherTrigger label, .sub.autoAttachOnAnyOtherTrigger select'); + const disabled = configs.autoAttachOnAnyOtherTrigger == Constants.kNEWTAB_DO_NOTHING; + for (const node of nodes) { + if ('disabled' in node) + node.disabled = disabled; + else + node.setAttribute('disabled', disabled); + } + }; break; + + case 'showExpertOptions': { + if (mShowExpertOptionsTemporarily && !configs.showExpertOptions) + document.querySelector('#showExpertOptions').checked = true; + const show = mShowExpertOptionsTemporarily || configs.showExpertOptions; + document.documentElement.classList.toggle('show-expert-options', show); + for (const item of document.querySelectorAll('#parentTabOperationBehaviorModeGroup li li')) { + const radio = item.querySelector('input[type="radio"]'); + if (show || radio.checked) { + item.style.display = ''; + radio.style.display = show || radio.checked ? '' : 'none'; + } + else { + item.style.display = radio.style.display = 'none'; + } + } + if (mShowExpertOptionsTemporarily && !configs.showExpertOptions) + mShowExpertOptionsTemporarily = false; + }; break; + + case 'syncDeviceInfo': { + const name = (configs.syncDeviceInfo || {}).name || ''; + const nameField = document.querySelector('#syncDeviceInfoName'); + if (name != nameField.value) + nameField.value = name; + const icon = (configs.syncDeviceInfo || {}).icon || ''; + const iconRadio = document.querySelector(`#syncDeviceInfoIcon input[type="radio"][value=${JSON.stringify(sanitizeForHTMLText(icon))}]`); + if (iconRadio && !iconRadio.checked) + iconRadio.checked = true; + }; break; + + case 'syncDevices': + initOtherDevices(); + break; + + case 'userStyleRulesFieldTheme': + applyUserStyleRulesFieldTheme(); + break; + + case 'userStyleRulesFieldHeight': + mUserStyleRulesField.style.height = configs.userStyleRulesFieldHeight; + break; + + default: + if (key.startsWith('chunkedUserStyleRules') && + !mUserStyleRulesField.$saving) + mUserStyleRulesFieldEditor.setValue(loadUserStyleRules()); + break; + } +} + +function removeAccesskeyMark(node) { + if (!node.nodeValue) + return; + node.nodeValue = sanitizeAccesskeyMark(node.nodeValue); +} + +function onChangeParentCheckbox(event) { + const container = event.currentTarget.closest('fieldset'); + for (const checkbox of container.querySelectorAll('p input[type="checkbox"]')) { + checkbox.checked = event.currentTarget.checked; + } + saveLogForConfig(); +} + +function onChangeChildCheckbox(event) { + getParentCheckboxFromChild(event.currentTarget).checked = isAllChildrenChecked(event.currentTarget); + saveLogForConfig(); +} + +function getParentCheckboxFromChild(child) { + const container = child.closest('fieldset'); + return container.querySelector('legend input[type="checkbox"]'); +} + +async function onChangeBookmarkPermissionRequiredCheckboxState(event) { + const permissionCheckbox = document.getElementById('bookmarksPermissionGranted'); + if (permissionCheckbox.checked) + return; + + permissionCheckbox.checked = true; + permissionCheckbox.requestPermissions(); + + const checkbox = event.currentTarget; + const key = checkbox.name || checkbox.id || checkbox.dataset.configKey; + setTimeout(() => { + checkbox.checked = true; + setTimeout(() => { + configs[key] = true; + }, 300); // 250 msec is the minimum delay of throttle update + }, 100); +} + +function updateCtrlTabSubItems(enabled) { + const elements = document.querySelectorAll('#ctrlTabSubItemsContainer label, #ctrlTabSubItemsContainer input, #ctrlTabSubItemsContainer select'); + if (enabled) { + for (const element of elements) { + element.removeAttribute('disabled'); + } + } + else { + for (const element of elements) { + element.setAttribute('disabled', true); + } + } +} + + +function reserveToSaveUserStyleRules() { + if (reserveToSaveUserStyleRules.timer) + clearTimeout(reserveToSaveUserStyleRules.timer); + reserveToSaveUserStyleRules.timer = setTimeout(() => { + reserveToSaveUserStyleRules.timer = null; + const caution = document.querySelector('#tooLargeUserStyleRulesCaution'); + mUserStyleRulesField.$saving = true; + if (reserveToSaveUserStyleRules.clearFlagTimer) + clearTimeout(reserveToSaveUserStyleRules.clearFlagTimer); + try { + saveUserStyleRules(mUserStyleRulesFieldEditor.getValue()); + mUserStyleRulesField.classList.remove('invalid'); + caution.classList.remove('invalid'); + } + catch(_error) { + mUserStyleRulesField.classList.add('invalid'); + caution.classList.add('invalid'); + } + finally { + reserveToSaveUserStyleRules.clearFlagTimer = setTimeout(() => { + delete reserveToSaveUserStyleRules.clearFlagTimer; + mUserStyleRulesField.$saving = false; + }, 1000); + } + }, 250); +} +reserveToSaveUserStyleRules.timer = null; + +function getUserStyleRulesFieldTheme() { + if (configs.userStyleRulesFieldTheme != 'auto') + return configs.userStyleRulesFieldTheme; + + return mDarkModeMedia.matches ? 'bespin' : 'default'; +} + +function applyUserStyleRulesFieldTheme() { + const theme = getUserStyleRulesFieldTheme(); + if (theme != 'default' && + !document.querySelector(`link[href$="/extlib/codemirror-theme/${theme}.css"]`)) { + document.querySelector('head').insertAdjacentHTML('beforeend', ` + + `.trim()); + } + mUserStyleRulesFieldEditor.setOption('theme', theme); +} + + +function saveLogForConfig() { + const config = {}; + for (const checkbox of document.querySelectorAll('p input[type="checkbox"][id^="logFor-"]')) { + config[checkbox.id.replace(/^logFor-/, '')] = checkbox.checked; + } + configs.logFor = config; +} + +function isAllChildrenChecked(aMasger) { + const container = aMasger.closest('fieldset'); + const checkboxes = container.querySelectorAll('p input[type="checkbox"]'); + return Array.from(checkboxes).every(checkbox => checkbox.checked); +} + +async function updateBookmarksUI(enabled) { + const elements = document.querySelectorAll('.with-bookmarks-permission, .with-bookmarks-permission label, .with-bookmarks-permission input, .with-bookmarks-permission button'); + if (enabled) { + for (const element of elements) { + element.removeAttribute('disabled'); + } + const defaultParentFolder = ( + (await Bookmark.getItemById(configs.defaultBookmarkParentId)) || + (await Bookmark.getItemById(configs.$default.defaultBookmarkParentId)) + ); + document.querySelector('#defaultBookmarkParentChooserStyle').textContent = Bookmark.FOLDER_CHOOSER_STYLE; + Bookmark.initFolderChooser({ + defaultValue: defaultParentFolder.id, + rootItems: (await browser.bookmarks.getTree().catch(ApiTabs.createErrorHandler()))[0].children, + container: document.querySelector('#defaultBookmarkParentGroup'), + inline: true, + }); + } + else { + for (const element of elements) { + element.setAttribute('disabled', true); + } + } + + const triboolChecks = document.querySelectorAll('input[type="checkbox"].require-bookmarks-permission, .require-bookmarks-permission input[type="checkbox"]'); + if (enabled) { + for (const checkbox of triboolChecks) { + checkbox.classList.remove('missing-permission'); + const message = checkbox.dataset.requestPermissionMessage; + if (message && checkbox.parentNode.getAttribute('title') == message) + checkbox.parentNode.removeAttribute('title'); + } + } + else { + for (const checkbox of triboolChecks) { + checkbox.classList.add('missing-permission'); + const message = checkbox.dataset.requestPermissionMessage; + if (message) + checkbox.parentNode.setAttribute('title', message); + } + } +} + +async function initOtherDevices() { + const devices = await Sync.getOtherDevices(); + const container = document.querySelector('#otherDevices'); + const range = document.createRange(); + range.selectNodeContents(container); + range.deleteContents(); + range.detach(); + for (const device of devices) { + const icon = device.icon ? `` : ''; + container.insertAdjacentHTML('beforeend', ` +
      • + `.trim()); + } +} + +function removeOtherDevice(id) { + const devices = JSON.parse(JSON.stringify(configs.syncDevices)); + if (!(id in devices)) + return; + delete devices[id]; + configs.syncDevices = devices; +} + + +async function showLogs() { + browser.tabs.create({ + url: '/resources/logs.html' + }); +} + +function initUserStyleImportExportButtons() { + const exportButton = document.getElementById('userStyleRules-export'); + exportButton.addEventListener('keydown', event => { + if (event.key == 'Enter' || event.key == ' ') + exportUserStyleToFile(); + }); + exportButton.addEventListener('click', event => { + if (event.button == 0) + exportUserStyleToFile(); + }); + + const importButton = document.getElementById('userStyleRules-import'); + importButton.addEventListener('keydown', event => { + if (event.key == 'Enter' || event.key == ' ') + document.getElementById('userStyleRules-import-file').click(); + }); + importButton.addEventListener('click', event => { + if (event.button == 0) + document.getElementById('userStyleRules-import-file').click(); + }); + + const fileField = document.getElementById('userStyleRules-import-file'); + fileField.addEventListener('change', async _event => { + importFilesToUserStyleRulesField(fileField.files); + }); +} + +function exportUserStyleToFile() { + const styleRules = mUserStyleRulesFieldEditor.getValue(); + const link = document.getElementById('userStyleRules-export-file'); + link.href = URL.createObjectURL(new Blob([styleRules], { type: 'text/css' })); + link.click(); +} + +// Due to https://bugzilla.mozilla.org/show_bug.cgi?id=1408756 we cannot accept dropped files on an embedded options page... +function initFileDragAndDropHandlers() { + mUserStyleRulesField.addEventListener('dragenter', event => { + event.stopPropagation(); + event.preventDefault(); + }, { capture: true }); + + mUserStyleRulesField.addEventListener('dragover', event => { + event.stopPropagation(); + event.preventDefault(); + + const dt = event.dataTransfer; + const hasFile = Array.from(dt.items, item => item.kind).some(kind => kind == 'file'); + dt.dropEffect = hasFile ? 'link' : 'none'; + }, { capture: true }); + + mUserStyleRulesField.addEventListener('drop', event => { + event.stopPropagation(); + event.preventDefault(); + + const dt = event.dataTransfer; + const files = dt.files; + if (files && files.length > 0) + importFilesToUserStyleRulesField(files); + }, { capture: true }); +} + +async function importFilesToUserStyleRulesField(files) { + files = Array.from(files); + if (files.some(file => file.type.startsWith('image/'))) { + const contents = await Promise.all(Array.from(files, file => { + switch (file.type) { + case 'text/plain': + case 'text/css': + return file.text(); + + default: + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.addEventListener('load', () => { + resolve(`url(${JSON.stringify(reader.result)})`); + }); + reader.addEventListener('error', event => { + reject(event); + }); + reader.readAsDataURL(file); + }); + } + })); + mUserStyleRulesFieldEditor.replaceSelection(contents.join('\n'), true); + } + else { + const style = (await Promise.all(files.map(file => file.text()))).join('\n'); + const current = mUserStyleRulesFieldEditor.getValue().trim(); + if (current == '') { + mUserStyleRulesFieldEditor.setValue(style); + return; + } + let result; + try { + result = await RichConfirm.showInPopup({ + modal: true, + type: 'common-dialog', + url: ((await Permissions.isGranted(Permissions.ALL_URLS)) ? null : '/resources/blank.html'), + title: browser.i18n.getMessage('config_userStyleRules_overwrite_title'), + message: browser.i18n.getMessage('config_userStyleRules_overwrite_message'), + buttons: [ + browser.i18n.getMessage('config_userStyleRules_overwrite_overwrite'), + browser.i18n.getMessage('config_userStyleRules_overwrite_append') + ] + }); + } + catch(_error) { + result = { buttonIndex: -1 }; + } + switch (result.buttonIndex) { + case 0: + mUserStyleRulesFieldEditor.setValue(style); + break; + case 1: + mUserStyleRulesFieldEditor.setValue(`${current}\n${style}`); + break; + default: + break; + } + } + mUserStyleRulesField.focus(); +} + +function updateThemeInformation(theme) { + const rules = BrowserTheme.generateThemeRules(theme) + .replace(/--theme-[^:]*-[0-9]+:[^;]*;\s*/g, '') /* hide alpha variations */ + .replace(/(#(?:[0-9a-f]{3,8})|(?:rgb|hsl)a?\([^\)]+\))/gi, `$1\u200b`); + const container = document.getElementById('browserThemeCustomRules'); + const range = document.createRange(); + range.selectNodeContents(container); + range.deleteContents(); + range.detach(); + container.insertAdjacentHTML('beforeend', rules); + document.getElementById('browserThemeCustomRulesBlock').style.display = rules ? 'block' : 'none'; +} + +function autoDetectDuplicatedTabDetectionDelay() { + browser.runtime.sendMessage({ type: Constants.kCOMMAND_AUTODETECT_DUPLICATED_TAB_DETECTION_DELAY }); +} + +async function testDuplicatedTabDetection() { + const successRate = await browser.runtime.sendMessage({ type: Constants.kCOMMAND_TEST_DUPLICATED_TAB_DETECTION }); + document.querySelector('#delayForDuplicatedTabDetection_testResult').textContent = browser.i18n.getMessage('config_delayForDuplicatedTabDetection_test_resultMessage', [successRate * 100]); +} + + +configs.$addObserver(onConfigChanged); +window.addEventListener('DOMContentLoaded', async () => { + try { + document.documentElement.classList.toggle('successor-tab-support', typeof browser.tabs.moveInSuccession == 'function'); + document.documentElement.classList.toggle('expose-unblock-autoplay-features', configs.exposeUnblockAutoplayFeatures); + + initAccesskeys(); + initLogsButton(); + initDuplicatedTabDetection(); + initLinks(); + initTheme(); + } + catch(error) { + console.error(error); + } + + await configs.$loaded; + + let focusedItem; + try { + focusedItem = initFocusedItem(); + initCollapsibleSections({ focusedItem }); + initPermissionOptions(); + initLogCheckboxes(); + initPreviews(); + initExternalAddons(); + initSync(); + } + catch(error) { + console.error(error); + } + + mShowExpertOptionsTemporarily = !!( + location.hash && + !/^#!?$/.test(location.hash) && + document.querySelector(`.expert #${location.hash.replace(/^#!?/, '')}, .expert#${location.hash.replace(/^#!?/, '')}`) + ); + + try { + options.buildUIForAllConfigs(document.querySelector('#group-allConfigs')); + onConfigChanged('successorTabControlLevel'); + onConfigChanged('showExpertOptions'); + await wait(0); + onConfigChanged('parentTabOperationBehaviorMode'); + onConfigChanged('autoAttachOnAnyOtherTrigger'); + onConfigChanged('syncDeviceInfo'); + + if (focusedItem) + focusedItem.scrollIntoView({ block: 'start' }); + } + catch(error) { + console.error(error); + } + + document.documentElement.classList.add('initialized'); +}, { once: true }); + +function initAccesskeys() { + for (const label of document.querySelectorAll('.contextConfigs label')) { + for (const child of label.childNodes) { + if (child.nodeType == Node.TEXT_NODE) + removeAccesskeyMark(child); + } + } +} + +function initLogsButton() { + const showLogsButton = document.getElementById('showLogsButton'); + showLogsButton.addEventListener('click', event => { + if (event.button != 0) + return; + showLogs(); + }); + showLogsButton.addEventListener('keydown', event => { + if (event.key != 'Enter') + return; + showLogs(); + }); +} + +function initDuplicatedTabDetection() { + const autoDetectDuplicatedTabDetectionDelayButton = document.getElementById('delayForDuplicatedTabDetection_autoDetectButton'); + autoDetectDuplicatedTabDetectionDelayButton.addEventListener('click', event => { + if (event.button != 0) + return; + autoDetectDuplicatedTabDetectionDelay(); + }); + autoDetectDuplicatedTabDetectionDelayButton.addEventListener('keydown', event => { + if (event.key != 'Enter') + return; + autoDetectDuplicatedTabDetectionDelay(); + }); + + const testDuplicatedTabDetectionButton = document.getElementById('delayForDuplicatedTabDetection_testButton'); + testDuplicatedTabDetectionButton.addEventListener('click', event => { + if (event.button != 0) + return; + testDuplicatedTabDetection(); + }); + testDuplicatedTabDetectionButton.addEventListener('keydown', event => { + if (event.key != 'Enter') + return; + testDuplicatedTabDetection(); + }); +} + +function initLinks() { + document.getElementById('link-optionsPage-top').setAttribute('href', `${location.href.split('#')[0]}#!`); + document.getElementById('link-optionsPage').setAttribute('href', `${location.href.split('#')[0]}#!`); + document.getElementById('link-startupPage').setAttribute('href', Constants.kSHORTHAND_URIS.startup); + document.getElementById('link-groupPage').setAttribute('href', Constants.kSHORTHAND_URIS.group); + document.getElementById('link-tabbarPage').setAttribute('href', Constants.kSHORTHAND_URIS.tabbar); + const runTestLink = document.getElementById('link-runTests'); + const runTestParameters = document.getElementById('runTestsParameters'); + runTestLink.setAttribute('href', '#'); + runTestLink.addEventListener('mousedown', _event => { + runTestLink.href = `${Constants.kSHORTHAND_URIS.testRunner}?${runTestParameters.value || ''}`; + }, true); + runTestLink.addEventListener('keydown', _event => { + runTestLink.href = `${Constants.kSHORTHAND_URIS.testRunner}?${runTestParameters.value || ''}`; + }, true); + runTestParameters.addEventListener('keydown', event => { + if (event.key != 'Enter') + return; + browser.tabs.create({ + url: `${Constants.kSHORTHAND_URIS.testRunner}?${runTestParameters.value || ''}`, + }); + }); + document.getElementById('link-runBenchmark').setAttribute('href', `${Constants.kSHORTHAND_URIS.testRunner}?benchmark=true`); +} + +function initTheme() { + if (browser.theme?.getCurrent) { + browser.theme.getCurrent().then(updateThemeInformation); + browser.theme.onUpdated.addListener(updateInfo => updateThemeInformation(updateInfo.theme)); + } +} + +function initFocusedItem() { + const focusedItem = document.querySelector(':target'); + for (const fieldset of document.querySelectorAll('fieldset.collapsible')) { + if (configs.optionsExpandedGroups.includes(fieldset.id) || + (focusedItem && fieldset.contains(focusedItem))) + fieldset.classList.remove('collapsed'); + else + fieldset.classList.add('collapsed'); + + const onChangeCollapsed = () => { + if (!fieldset.id) + return; + const otherExpandedSections = configs.optionsExpandedGroups.filter(id => id != fieldset.id); + if (fieldset.classList.contains('collapsed')) + configs.optionsExpandedGroups = otherExpandedSections; + else + configs.optionsExpandedGroups = otherExpandedSections.concat([fieldset.id]); + }; + + const legend = fieldset.querySelector(':scope > legend'); + legend.addEventListener('click', () => { + fieldset.classList.toggle('collapsed'); + onChangeCollapsed(); + }); + legend.addEventListener('keydown', event => { + if (event.key != 'Enter') + return; + fieldset.classList.toggle('collapsed'); + onChangeCollapsed(); + }); + } + + return focusedItem; +} + +function initCollapsibleSections({ focusedItem }) { + for (const heading of document.querySelectorAll('body > section > h1')) { + const section = heading.parentNode; + section.style.maxHeight = `${heading.offsetHeight}px`; + if (!configs.optionsExpandedSections.includes(section.id) && + (!focusedItem || !section.contains(focusedItem))) + section.classList.add('collapsed'); + heading.addEventListener('click', () => { + section.classList.toggle('collapsed'); + const otherExpandedSections = configs.optionsExpandedSections.filter(id => id != section.id); + if (section.classList.contains('collapsed')) + configs.optionsExpandedSections = otherExpandedSections; + else + configs.optionsExpandedSections = otherExpandedSections.concat([section.id]); + }); + } +} + +function initPermissionOptions() { + configs.requestingPermissionsNatively = null; + + Permissions.isGranted(Permissions.BOOKMARKS).then(granted => updateBookmarksUI(granted)); + Permissions.isGranted(Permissions.ALL_URLS).then(granted => updateCtrlTabSubItems(granted)); + + Permissions.bindToCheckbox( + Permissions.ALL_URLS, + document.querySelector('#allUrlsPermissionGranted_tabPreviewTooltip') + ); + Permissions.bindToCheckbox( + Permissions.ALL_URLS, + document.querySelector('#allUrlsPermissionGranted_ctrlTabTracking'), + { + onChanged(granted) { + updateCtrlTabSubItems(granted); + }, + } + ); + + const bookmarksPermissionCheckboxes = [ + document.querySelector('#bookmarksPermissionGranted'), + document.querySelector('#bookmarksPermissionGranted_autoGroup'), + document.querySelector('#bookmarksPermissionGranted_context'), + ]; + for (const checkbox of bookmarksPermissionCheckboxes) { + Permissions.bindToCheckbox( + Permissions.BOOKMARKS, + checkbox, + { onChanged: (granted) => updateBookmarksUI(granted) } + ); + } + + Permissions.bindToCheckbox( + Permissions.CLIPBOARD_READ, + document.querySelector('#clipboardReadPermissionGranted_middleClickPasteURLOnNewTabButton'), + { + onInitialized: (granted) => { + return granted && configs.middleClickPasteURLOnNewTabButton; + }, + onChanged: (granted) => { + configs.middleClickPasteURLOnNewTabButton = granted; + } + } + ); + + Permissions.bindToCheckbox( + Permissions.TAB_HIDE, + document.querySelector('#tabHidePermissionGranted'), + { onChanged: async (granted) => { + if (granted) { + // try to hide/show the tab to ensure the permission is really granted + let activeTabs = await browser.tabs.query({ active: true, currentWindow: true }); + if (activeTabs.length == 0) + activeTabs = await browser.tabs.query({ currentWindow: true }); + const tab = await browser.tabs.create({ active: false, windowId: activeTabs[0].windowId }); + await wait(200); + let aborted = false; + const onRemoved = tabId => { + if (tabId != tab.id) + return; + aborted = true; + browser.tabs.onRemoved.removeListener(onRemoved); + // eslint-disable-next-line no-use-before-define + browser.tabs.onUpdated.removeListener(onUpdated); + }; + const onUpdated = async (tabId, changeInfo, tab) => { + if (tabId != tab.id || + !('hidden' in changeInfo)) + return; + await wait(60 * 1000); + if (aborted) + return; + await browser.tabs.show([tab.id]); + await browser.tabs.remove(tab.id); + }; + browser.tabs.onRemoved.addListener(onRemoved); + browser.tabs.onUpdated.addListener(onUpdated); + await browser.tabs.hide([tab.id]); + } + }} + ); + + for (const checkbox of document.querySelectorAll('input[type="checkbox"].require-bookmarks-permission')) { + checkbox.addEventListener('change', onChangeBookmarkPermissionRequiredCheckboxState); + } + + for (const node of document.querySelectorAll('.with-bookmarks-permission')) { + Permissions.bindToClickable( + Permissions.BOOKMARKS, + node, + { + onChanged: (granted) => updateBookmarksUI(granted), + } + ); + } +} + +function initLogCheckboxes() { + for (const checkbox of document.querySelectorAll('p input[type="checkbox"][id^="logFor-"]')) { + checkbox.addEventListener('change', onChangeChildCheckbox); + checkbox.checked = configs.logFor[checkbox.id.replace(/^logFor-/, '')]; + } + for (const checkbox of document.querySelectorAll('legend input[type="checkbox"][id^="logFor-"]')) { + checkbox.checked = isAllChildrenChecked(checkbox); + checkbox.addEventListener('change', onChangeParentCheckbox); + } +} + +function initPreviews() { + for (const previewImage of document.querySelectorAll('select ~ .preview-image')) { + const container = previewImage.parentNode; + container.classList.add('has-preview-image'); + const select = container.querySelector('select'); + container.dataset.value = select.dataset.value = select.value; + container.addEventListener('mouseover', event => { + if (event.target != select && + select.contains(event.target)) + return; + const rect = select.getBoundingClientRect(); + previewImage.style.insetInlineStart = `${isRTL() ? rect.right : rect.left}px`; + previewImage.style.top = `${rect.top - 5 - previewImage.offsetHeight}px`; + }); + select.addEventListener('change', () => { + container.dataset.value = select.dataset.value = select.value; + }); + select.addEventListener('mouseover', event => { + if (event.target == select) + return; + container.dataset.value = select.dataset.value = event.target.value; + }); + select.addEventListener('mouseout', () => { + container.dataset.value = select.dataset.value = select.value; + }); + } +} + +async function initExternalAddons() { + let addons; + while (true) { + // This API call will fail if the background page is not initialized yet, so we need to retry it a while. + addons = await browser.runtime.sendMessage({ + type: TSTAPI.kCOMMAND_GET_ADDONS, + }); + if (addons) + break; + await wait(250); + } + + const description = document.getElementById('externalAddonPermissionsGroupDescription'); + description.insertAdjacentHTML('beforeend', browser.i18n.getMessage('config_externaladdonpermissions_description')); + + const container = document.getElementById('externalAddonPermissions'); + for (const addon of addons) { + if (addon.id == browser.runtime.id) + continue; + const row = document.createElement('tr'); + + const nameCell = row.appendChild(document.createElement('td')); + const nameLabel = nameCell.appendChild(document.createElement('label')); + nameLabel.appendChild(document.createTextNode(addon.label)); + const controlledId = `api-permissions-${encodeURIComponent(addon.id)}`; + nameLabel.setAttribute('for', controlledId); + + const incognitoCell = row.appendChild(document.createElement('td')); + const incognitoLabel = incognitoCell.appendChild(document.createElement('label')); + const incognitoCheckbox = incognitoLabel.appendChild(document.createElement('input')); + if (addon.permissions.length == 0) + incognitoCheckbox.setAttribute('id', controlledId); + incognitoCheckbox.setAttribute('type', 'checkbox'); + incognitoCheckbox.checked = configs.incognitoAllowedExternalAddons.includes(addon.id); + incognitoCheckbox.addEventListener('change', () => { + const updatedValue = new Set(configs.incognitoAllowedExternalAddons); + if (incognitoCheckbox.checked) + updatedValue.add(addon.id); + else + updatedValue.delete(addon.id); + configs.incognitoAllowedExternalAddons = Array.from(updatedValue); + browser.runtime.sendMessage({ + type: TSTAPI.kCOMMAND_NOTIFY_PERMISSION_CHANGED, + id: addon.id + }); + }); + + const permissionsCell = row.appendChild(document.createElement('td')); + if (addon.permissions.length > 0) { + const permissionsLabel = permissionsCell.appendChild(document.createElement('label')); + const permissionsCheckbox = permissionsLabel.appendChild(document.createElement('input')); + permissionsCheckbox.setAttribute('id', controlledId); + permissionsCheckbox.setAttribute('type', 'checkbox'); + permissionsCheckbox.checked = addon.permissionsGranted; + permissionsCheckbox.addEventListener('change', () => { + browser.runtime.sendMessage({ + type: TSTAPI.kCOMMAND_SET_API_PERMISSION, + id: addon.id, + permissions: permissionsCheckbox.checked ? addon.permissions : addon.permissions.map(permission => `! ${permission}`) + }); + }); + const permissionNames = addon.permissions.map(permission => { + try { + return browser.i18n.getMessage(`api_requestedPermissions_type_${permission}`) || permission; + } + catch(_error) { + return permission; + } + }).join(', '); + permissionsLabel.appendChild(document.createTextNode(permissionNames)); + } + + container.appendChild(row); + } +} + +function initSync() { + const section = document.querySelector('#syncTabsToDeviceOptions'); + if (Sync.hasExternalProvider()) { + section.classList.add('hidden'); + return; + } + + section.classList.remove('hidden'); + + const deviceInfoNameField = document.querySelector('#syncDeviceInfoName'); + deviceInfoNameField.addEventListener('input', () => { + if (deviceInfoNameField.$throttling) + clearTimeout(deviceInfoNameField.$throttling); + deviceInfoNameField.$throttling = setTimeout(async () => { + delete deviceInfoNameField.$throttling; + configs.syncDeviceInfo = JSON.parse(JSON.stringify({ + ...(configs.syncDeviceInfo || await Sync.generateDeviceInfo()), + name: deviceInfoNameField.value + })); + }, 250); + }); + + const deviceInfoIconRadiogroup = document.querySelector('#syncDeviceInfoIcon'); + deviceInfoIconRadiogroup.addEventListener('change', _event => { + if (deviceInfoIconRadiogroup.$throttling) + clearTimeout(deviceInfoIconRadiogroup.$throttling); + deviceInfoIconRadiogroup.$throttling = setTimeout(async () => { + delete deviceInfoIconRadiogroup.$throttling; + const checkedRadio = deviceInfoIconRadiogroup.querySelector('input[type="radio"]:checked'); + configs.syncDeviceInfo = JSON.parse(JSON.stringify({ + ...(configs.syncDeviceInfo || await Sync.generateDeviceInfo()), + icon: checkedRadio.value + })); + }, 250); + }); + + initOtherDevices(); + + const otherDevices = document.querySelector('#otherDevices'); + otherDevices.addEventListener('click', event => { + if (event.target.localName != 'button') + return; + const item = event.target.closest('li'); + removeOtherDevice(item.id.replace(/^otherDevice:/, '')); + }); + otherDevices.addEventListener('keydown', event => { + if (event.key != 'Enter' || + event.target.localName != 'button') + return; + const item = event.target.closest('li'); + removeOtherDevice(item.id.replace(/^otherDevice:/, '')); + }); +} + +import('/extlib/codemirror.js').then(async () => { + await Promise.all([ + configs.$loaded, + import('/extlib/codemirror-mode-css.js'), + import('/extlib/codemirror-colorpicker.js') + ]); + mUserStyleRulesFieldEditor = CodeMirror(mUserStyleRulesField, { // eslint-disable-line no-undef + colorpicker: { + mode: 'edit' + }, + lineNumbers: true, + lineWrapping: true, + mode: 'css', + theme: getUserStyleRulesFieldTheme() + }); + mDarkModeMedia.addListener(async _event => { + applyUserStyleRulesFieldTheme(); + }); + applyUserStyleRulesFieldTheme(); + window.mUserStyleRulesFieldEditor = mUserStyleRulesFieldEditor; + mUserStyleRulesFieldEditor.setValue(loadUserStyleRules()); + mUserStyleRulesFieldEditor.on('change', reserveToSaveUserStyleRules); + mUserStyleRulesFieldEditor.on('update', reserveToSaveUserStyleRules); + initUserStyleImportExportButtons(); + initFileDragAndDropHandlers(); + mUserStyleRulesField.style.height = configs.userStyleRulesFieldHeight; + (new ResizeObserver(_entries => { + configs.userStyleRulesFieldHeight = `${mUserStyleRulesField.offsetHeight}px`; + })).observe(mUserStyleRulesField); +}); diff --git a/waterfox/browser/components/sidebar/options/options.css b/waterfox/browser/components/sidebar/options/options.css new file mode 100644 index 000000000000..14a5b94ebf10 --- /dev/null +++ b/waterfox/browser/components/sidebar/options/options.css @@ -0,0 +1,388 @@ +/* +# 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 url(/resources/ui-base.css); +@import url(/resources/ui-color.css); + +:root.rtl { + direction: rtl; +} + +:root > * { + transition: opacity 0.25s ease-out; +} +:root:not(.initialized) > * { + opacity: 0; +} + +/* +body { + background: var(--in-content-box-background); + color: var(--text-color); +} +*/ + +@media (prefers-color-scheme: dark) { + body { + background: var(--in-content-box-background); + color: var(--text-color); + } +} + +body.independent { + background: var(--bg-color); + box-sizing: border-box; + color: var(--text-color); + cursor: default; + display: flex; + flex-direction: column; + font: caption; + margin-block: 0; + margin-inline: 0; + padding-block: 1em 100%; + padding-inline: 1em; + -moz-user-select: none; +} + +em { + font-style: normal; + font-weight: bold; +} + +p, ul { + margin-block: 0 0.5em; + margin-inline: 0; + padding-block: 0; + padding-inline: 0; +} + +ul, +ul li { + list-style: none; +} + +p.sub, +div.sub { + padding-inline-start: 2em; +} + +ul p.sub, +ul div.sub p { + margin-block: 0; +} + +pre { + white-space: pre-wrap; +} + +label[disabled="true"], +input[disabled="true"] { + opacity: 0.5; +} +label[disabled="true"] input, +label[disabled="true"] button { + opacity: 1; +} + +label.has-checkbox:not(.inline), +label.has-radio:not(.inline) { + margin-inline-start: 2em; + text-indent: -2em; + display: block; + width: -moz-fit-content; +} + +.hidden, +:root:not(.expose-unblock-autoplay-features) .unblock-autoplay-features { + display: none; +} + + +#style-radiogroup { + align-content: flex-start; + align-items: center; + display: flex; + flex-direction: row; + flex-wrap: wrap; + padding-block: 0; + padding-inline: 0; +} + +#style-radiogroup li { + align-items: center; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + margin-block-end: 0.5em; + padding-block: 0; + padding-inline: 0; + width: calc(1em + 100px + 8em); /* radio + image + label */ +} + +#style-radiogroup li img { + max-width: 100px; +} + +.style-name { + white-space: pre; +} + +#applyThemeColorToIcon ~ img { + max-width: 85px; +} + +#showTabDragBehaviorNotification ~ img { + max-width: 120px; +} + +label input[type="radio"] ~ img, +label input[type="checkbox"] ~ img { + border: 1px solid var(--ThreeDShadow); + vertical-align: middle; + margin-block: 0.15em; +} + +#syncTabsToDeviceOptions label input[type="radio"] ~ img { + border: 0 none; +} + +.preview-image { + background: no-repeat; + background-size: contain; + box-shadow: 0.25em 0.25em 0.5em rgba(0, 0, 0, 0.45); + display: inline-block; + height: 125px /*83px*/; + inset-inline-end: 0; + margin-inline-start: 0.5em; + opacity: 0; + pointer-events: none; + position: fixed; + top: 1.5em; + transition: opacity 0.25s ease-out; + width: 120px /*80px*/; +} +.has-preview-image:not([data-value="-1"]):hover .preview-image { + opacity: 1; +} +.has-preview-image[data-value="0"] .preview-image { + background-image: url("as-independent.png"); +} +.has-preview-image[data-value="1"] .preview-image, +.has-preview-image[data-value="5"] .preview-image, +.has-preview-image[data-value="6"] .preview-image, +.has-preview-image[data-value="7"] .preview-image { + background-image: url("as-child.png"); +} +.has-preview-image[data-value="2"] .preview-image { + background-image: url("as-sibling.png"); +} +.has-preview-image[data-value="3"] .preview-image, +.has-preview-image[data-value="4"] .preview-image { + background-image: url("as-next-sibling.png"); +} + + + +#browserThemeCustomRules { + -moz-user-select: text; + cursor: text; +} + +#tooLargeUserStyleRulesCaution { + color: var(--red-50); + margin-block-end: 0; + visibility: hidden; +} +#tooLargeUserStyleRulesCaution.invalid { + visibility: visible; +} + +#userStyleRulesField { + margin-block: 0; + margin-inline: -5px; + min-height: 10em; + overflow: hidden; + resize: vertical; +} + +#userStyleRulesField > .CodeMirror { + border: thin solid; + height: calc(100% - 10px); + margin-block: 5px; + margin-inline: 5px; +} + +#userStyleRulesField > .CodeMirror.CodeMirror-focused { + border-color: var(--in-content-border-focus); + box-shadow: 0 0 0 1px var(--in-content-border-active), + 0 0 0 4px var(--in-content-border-active-shadow); +} + +#userStyleRulesField.invalid > .CodeMirror { + border-color: var(--red-50); + --in-content-border-active: var(--red-50); + --in-content-border-active-shadow: var(--red-50-a30); +} + +#userStyleRulesField.invalid .CodeMirror-scroll { + background-color: var(--red-50-a10); +} + +.CodeMirror-wrap pre.CodeMirror-line, +.CodeMirror-wrap pre.CodeMirror-line-like { + word-break: break-all !important; +} + +#userStyleRules-footer-bar { + display: flex; + flex-direction: row; +} + +#userStyleRules-footer-bar button { + margin-inline-end: 0.5em; +} + +#userStyleRules-footer-bar .spacer { + display: flex; + flex-grow: 1; +} + + +table { + border-collapse: collapse; +} +th + *, +td + * { + border-inline-start: 1px solid light-dark(rgba(0, 0, 0, 0.1), rgba(255, 255, 255, 0.1)); +} +tbody tr { + border-block-start: 1px solid light-dark(rgba(0, 0, 0, 0.2), rgba(255, 255, 255, 0.2)); +} + + +.contextConfigs tbody th, +.tabDragBehaviorConfigs tbody th { + font-weight: normal; +} + +.contextConfigs tbody th, +.tabDragBehaviorConfigs thead th, +.tabDragBehaviorConfigs tbody th { + text-align: start; +} + +.contextConfigs td, +.tabDragBehaviorConfigs td { + text-align: center; +} + +.contextConfigs table input[type="checkbox"], +.tabDragBehaviorConfigs table input[type="radio"] { + margin-inline: 1.5em; +} + + +#externalAddonPermissionsGroup td:nth-child(2) { + text-align: center; +} +#externalAddonPermissionsGroup td:nth-child(2) input[type="checkbox"] { + margin-inline: 3em; +} + + +fieldset.collapsible.collapsed > *:not(legend):not(div) /* "div" is for the container of "import" and "export" buttons */ { + display: none; +} + +fieldset.collapsible > legend::before, +body > section > h1:first-child::before { + content: "▼"; + display: inline-block; + font-size: 65%; + margin-inline-end: 0.5em; + position: relative; + transition: transform 0.2s ease; +} + +fieldset.collapsible.collapsed > legend::before, +body > section.collapsed > h1:first-child::before { + transform: rotate(-90deg); +} + +:root.rtl fieldset.collapsible.collapsed > legend::before, +:root.rtl body > section.collapsed > h1:first-child::before { + transform: rotate(90deg); +} + + +:root.successor-tab-support .without-successor-tab-support, +:root:not(.successor-tab-support) .with-successor-tab-support { + display: none; +} + + +body > section { + margin-block-start: 1em; + overflow: hidden; + transition: max-height 0.2s ease-out; +} +body > section:not(.collapsed) { + max-height: none !important; +} + +body > section > h1:first-child { + margin-block-start: 0; + cursor: pointer; +} + +body > section > h1:first-child ~ * { + opacity: 1; + transition: opacity 0.2s ease-out; +} +body > section.collapsed > h1:first-child ~ * { + opacity: 0; +} + + +#defaultBookmarkParentChooser { + max-width: 100%; +} + +:target { + box-shadow: 0 0 0.5em highlight; + margin-block: 0.5em; +} + + +:root:not(.show-expert-options) .expert:not(option.expert[selected]) { + display: none; +} + +.expert:not(option) { + background: var(--in-content-box-info-background); + box-shadow: 0 0 0.5em var(--in-content-box-info-background), + 0 0 0.5em var(--in-content-box-info-background), + 0 0 0.5em var(--in-content-box-info-background); +} + + +#link-optionsPage-top { + float: inline-end; + padding-inline-end: 1em; +} + +body.independent #link-optionsPage-top { + display: none; +} + + +input[type="checkbox"].missing-permission { + opacity: 0.5; +} +input[type="checkbox"].missing-permission, +input[type="checkbox"].missing-permission ~ * { + cursor: help; +} diff --git a/waterfox/browser/components/sidebar/options/options.html b/waterfox/browser/components/sidebar/options/options.html new file mode 100644 index 000000000000..0de0c80ad66d --- /dev/null +++ b/waterfox/browser/components/sidebar/options/options.html @@ -0,0 +1,1661 @@ + + + + + + + + + + + + +

        __MSG_config_openOptionsInTab_label__ +

        +
        +

        __MSG_config_appearance_caption__

        + +

        __MSG_config_sidebarPosition_caption__ + + + +

        +

        __MSG_config_sidebarPosition_description__

        +
        + __MSG_config_style_caption__ +
          +
        • +
        • +
        • +
        • +
        • +
        +
        +

        +
        +

        +

        __MSG_config_labelOverflowStyle_caption__ + + +

        +

        +

        +

        +

        +

        +

        +
        +

        +
        +

        +

        +

        +

        +

        +

        +
        + __MSG_config_suppressGapFromShownOrHiddenToolbar_caption__ +

        +

        +
        +

        +
        +

        + +

        +

        +

        +
        +
        + +
        +

        __MSG_config_context_caption__

        +
        + __MSG_config_extraItems_tabs_caption__ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
        __MSG_config_extraItems_tabs_topLevel____MSG_config_extraItems_tabs_subMenu____MSG_config_extraItems_tabs_middleClick__
        +

        +
        +
        + +
        +

        +

        +
        +
        + +

        + + + +

        +
        +
        +
          +
          + +
          +
          +
          +
          + __MSG_config_sendTabsToDevice_caption__ +

          __MSG_config_syncRequirements_description__

          +

          +

          + + + + + + + + +

          +

          __MSG_config_syncDeviceInfo_name_description_before____MSG_config_syncDeviceInfo_name_description_link____MSG_config_syncDeviceInfo_name_description_after__

          +
          + __MSG_config_otherDevices_caption__ +
            +
            +
            +

            +

            +
            +
            +
            + __MSG_config_extraItems_bookmarks_caption__ +

            +

            +
            +

            +

            +
            +

            +
            + + +
            +

            __MSG_config_newTabWithOwner_caption__

            + +

            +
            +
            + __MSG_config_insertNewTabFromFirefoxViewAt_caption__ +
              +
            • +
            • +
            • +
            • +
            +
            +

            +

            +
            +
            + __MSG_config_insertNewTabFromPinnedTabAt_caption__ +
              +
            • +
            • +
            • +
            • +
            +
            +

            +

            +
            +
            +
            + + +
            +

            __MSG_config_newTab_caption__

            + +
            + __MSG_config_newTabAction_caption__ +

            +

            +

            +
            +
            +
            + __MSG_config_newTabButton_caption__ +

            +

            +

            +

            +

            +

            +
            +
            + __MSG_config_autoAttachWithURL_caption__ +

            +

            +

            +

            +

            +

            +
            +

            + __MSG_config_autoAttachOnAnyOtherTrigger_caution__

            +

            +
            + +
            + __MSG_config_groupTab_caption__ +

            +

            +

            +

            +

            +

            +

            +
            +

            +
            +
            +

            +
            + +
            + __MSG_config_groupTabTemporaryState_caption__ +

            +

            +

            +

            +
            + +
            + +
            + + +
            +

            __MSG_config_treeBehavior_caption__

            + +
            +

            +

            +

            +

            +

            +

            +

            +
            + +

            +
            +

            +

            +
            + +
            + __MSG_config_treeDoubleClickBehavior_caption__ +
              +
            • +
            • +
            • +
            • +
            +
            +
            + __MSG_config_successorTabControlLevel_caption__ +
              +
            • +
            • +
            • +
            +

            __MSG_config_successorTabControlLevel_legacyDescription__

            +

            +

            +
            +
            + __MSG_config_parentTabOperationBehaviorMode_caption__ +

            __MSG_config_parentTabOperationBehaviorMode_noteForPermanentlyConsistentBehaviors__

            +
            +
              +
            • +
              +

              __MSG_config_closeParentBehavior_insideSidebar__

              +
                +
              • +
              • +
              • +
              • +
              +
              +

              __MSG_config_closeParentBehavior_outsideSidebar__

              +
                +
              • +
              • +
              • +
              • +
              +
              +

              __MSG_config_moveParentBehavior_outsideSidebar__

              +
                +
              • +
              • +
              • +
              • +
              • +
              +
              +
            • +
            • +
              +

              __MSG_config_parentTabOperationBehaviorMode_consistent_caption__

              +
                +
              • +
              • +
              • +
              • +
              +

              __MSG_config_parentTabOperationBehaviorMode_consistent_notes__

              +
              +
            • +
            • +
              +

              + +

              +
              +

              + +

              +

              + +

              +
              +

              + +

              +

              + +

              +
              +

              + +

              +

              + +

              +
              +

              + +

              +

              + +

              +
              +
            • +
            +
            +
            + __MSG_config_fixupTreeOnTabVisibilityChanged_caption__ +
              +
            • +
            • +
            +
            + +
            + __MSG_config_insertNewChildAt_caption__ +
              +
            • +
            • +
            • +
            • +
            +
            +
            + + +
            +

            __MSG_config_drag_caption__

            +
            + __MSG_config_tabDragBehavior_caption__ +

            __MSG_config_tabDragBehavior_description__

            + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
            __MSG_config_tabDragBehavior_label____MSG_config_tabDragBehaviorShift_label____MSG_config_tabDragBehavior_label_behaviorInsideSidebar____MSG_config_tabDragBehavior_label_behaviorOutsideSidebar__
            +

            +

            __MSG_config_tabDragBehavior_noteForDragstartOutsideSidebar__

            +
            +
            +
            + __MSG_config_dropLinksOnTabBehavior_caption__ +
              +
            • +
            • +
            • +
            +
            +

            +

            +
            +
            + __MSG_config_insertDroppedTabsAt_caption__ +
              +
            • +
            • +
            • +
            +
            +
            +

            +
            +

            +

            +

            +

            +
            +
            + + +
            +

            __MSG_config_advanced_caption__

            +

            +

            +
            +

            +

            +
            +

            __MSG_config_useCachedTree_description__

            +

            +

            __MSG_config_persistCachedTree_description__

            +
            +

            +

            +
            +

            +
            +

            +
            +
            + __MSG_config_userStyleRules_label__ +

            __MSG_config_userStyleRules_description_before__ + __MSG_config_userStyleRules_description_link_label__ + __MSG_config_userStyleRules_description_after__

            +
            +

            __MSG_config_userStyleRules_themeRules_description__

            +
            
            +        

            __MSG_config_userStyleRules_themeRules_description_alphaVariations__

            +
            +

            __MSG_config_tooLargeUserStyleRulesCaution__

            +
            + +
            +
            + +
            +

            __MSG_config_addons_caption__

            +

            __MSG_config_addons_description_before__ + __MSG_config_addons_description_link_label__ + __MSG_config_addons_description_after__

            +
            + __MSG_config_externalAddonPermissions_label__ +

            + + + + + + + + + +
            __MSG_config_externalAddonPermissions_header_name____MSG_config_externalAddonPermissions_header_incognito____MSG_config_externalAddonPermissions_header_permissions__
            +
            + +
            + __MSG_addon_containerBookmarks_label__ +

            +
            +
            + +
            + +
            +

            __MSG_config_debug_caption__

            + +

            __MSG_config_colorScheme_caption__ + + +

            +
            +

            +

            +

            +

            +

            +

            +

            +

            + +

            +

            + +

            +
            +

            + + +
            + + diff --git a/waterfox/browser/components/sidebar/options/style-highcontrast.png b/waterfox/browser/components/sidebar/options/style-highcontrast.png new file mode 100644 index 000000000000..c1676ceb85e4 Binary files /dev/null and b/waterfox/browser/components/sidebar/options/style-highcontrast.png differ diff --git a/waterfox/browser/components/sidebar/options/style-photon.png b/waterfox/browser/components/sidebar/options/style-photon.png new file mode 100644 index 000000000000..adf7a3ef7bd9 Binary files /dev/null and b/waterfox/browser/components/sidebar/options/style-photon.png differ diff --git a/waterfox/browser/components/sidebar/options/style-proton.png b/waterfox/browser/components/sidebar/options/style-proton.png new file mode 100644 index 000000000000..e20a628ce646 Binary files /dev/null and b/waterfox/browser/components/sidebar/options/style-proton.png differ diff --git a/waterfox/browser/components/sidebar/options/style-sidebar.png b/waterfox/browser/components/sidebar/options/style-sidebar.png new file mode 100644 index 000000000000..27a8c11ebe76 Binary files /dev/null and b/waterfox/browser/components/sidebar/options/style-sidebar.png differ diff --git a/waterfox/browser/components/sidebar/options/tab-drag-behavior-notification.png b/waterfox/browser/components/sidebar/options/tab-drag-behavior-notification.png new file mode 100644 index 000000000000..668d82246b28 Binary files /dev/null and b/waterfox/browser/components/sidebar/options/tab-drag-behavior-notification.png differ diff --git a/waterfox/browser/components/sidebar/resources/16x16.svg b/waterfox/browser/components/sidebar/resources/16x16.svg new file mode 100644 index 000000000000..1d66875d4573 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/16x16.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/20x20.svg b/waterfox/browser/components/sidebar/resources/20x20.svg new file mode 100644 index 000000000000..36e15fcd6263 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/20x20.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/24x24.svg b/waterfox/browser/components/sidebar/resources/24x24.svg new file mode 100644 index 000000000000..c171fcf3cb4c --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/24x24.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/32x32.svg b/waterfox/browser/components/sidebar/resources/32x32.svg new file mode 100644 index 000000000000..8cb5a6f0c52c --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/32x32.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/64x64.svg b/waterfox/browser/components/sidebar/resources/64x64.svg new file mode 100644 index 000000000000..7468a0e18e70 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/64x64.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/blank.html b/waterfox/browser/components/sidebar/resources/blank.html new file mode 100644 index 000000000000..3fc507fe2f4d --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/blank.html @@ -0,0 +1,9 @@ + + + + + + +

            __MSG_blank_allUrlsPermissionRequiredMessage__

            diff --git a/waterfox/browser/components/sidebar/resources/group-tab.html b/waterfox/browser/components/sidebar/resources/group-tab.html new file mode 100644 index 000000000000..560ed731e492 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/group-tab.html @@ -0,0 +1,319 @@ + + + + + + + + + + + +
            +
            +

            +

            +

            +

            __MSG_groupTab_options_label__ +

            +
            +
              + +
              + diff --git a/waterfox/browser/components/sidebar/resources/group-tab.js b/waterfox/browser/components/sidebar/resources/group-tab.js new file mode 100644 index 000000000000..37ceeff8c818 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/group-tab.js @@ -0,0 +1,632 @@ +/* +# 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'; + +(async function prepare(retryCount = 0) { + if (retryCount > 10) + throw new Error('could not prepare group tab contents'); + + if (!document.documentElement) { + setTimeout(prepare, 100, retryCount + 1); + return false; + } + + if (window.prepared || + document.documentElement.classList.contains('initialized')) + return false; + + window.prepared = true; + + let gTitle; + let gTitleField; + let gTemporaryCheck; + let gTemporaryAggressiveCheck; + let gBrowserThemeDefinition; + let gUserStyleRules; + + // Firefox sometimes clears the document title set at here, so we need to re-set it later. + document.title = getTitle(); + // Failsafe 1: This is effective on newly opened tabs, + // but won't effective on already opened and reinitialized tabs. + window.addEventListener('DOMContentLoaded', () => { + document.title = getTitle(); + }, { once: true }); + + function getTitle() { + const url = new URL(location.href); + let title = url.searchParams.get('title'); + if (!title) { + const matched = location.search.match(/^\?([^&;]*)/); + if (matched) + title = decodeURIComponent(matched[1]); + } + return title || browser.i18n.getMessage('groupTab_label_default'); + } + + function setTitle(title) { + if (!gTitle) + init(); + document.title = gTitle.textContent = gTitleField.value = title; + updateParameters({ title }); + } + + function isTemporary() { + const url = new URL(location.href); + return url.searchParams.get('temporary') == 'true'; + } + + function isTemporaryAggressive() { + const url = new URL(location.href); + return url.searchParams.get('temporaryAggressive') == 'true'; + } + + function getOpenerTabId() { + const url = new URL(location.href); + return url.searchParams.get('openerTabId'); + } + + function getAliasTabId() { + const url = new URL(location.href); + return url.searchParams.get('aliasTabId'); + } + + function getReplacedParentCount() { + const url = new URL(location.href); + const count = parseInt(url.searchParams.get('replacedParentCount')); + return isNaN(count) ? 0 : count; + } + + function enterTitleEdit() { + if (!gTitle) + init(); + gTitle.style.display = 'none'; + gTitleField.style.display = 'inline'; + gTitleField.select(); + gTitleField.focus(); + } + + function exitTitleEdit() { + if (!gTitle) + init(); + gTitle.style.display = ''; + gTitleField.style.display = ''; + } + + function hasModifier(event) { + return event.altKey || + event.ctrlKey || + event.metaKey || + event.shiftKey; + } + + function isAcceled(event) { + return /^Mac/i.test(navigator.platform) ? event.metaKey : event.ctrlKey; + } + + function updateParameters({ title } = {}) { + const url = new URL(location.href); + url.searchParams.set('title', title || getTitle() || ''); + + if (gTemporaryCheck.checked) + url.searchParams.set('temporary', 'true'); + else + url.searchParams.delete('temporary'); + + if (gTemporaryAggressiveCheck.checked) + url.searchParams.set('temporaryAggressive', 'true'); + else + url.searchParams.delete('temporaryAggressive'); + + const opener = getOpenerTabId(); + if (opener) + url.searchParams.set('openerTabId', opener); + else + url.searchParams.delete('openerTabId'); + + const aliasTabId = getAliasTabId(); + if (aliasTabId) + url.searchParams.set('aliasTabId', aliasTabId); + else + url.searchParams.delete('aliasTabId'); + + const replacedParentCount = getReplacedParentCount(); + if (replacedParentCount > 0) + url.searchParams.set('replacedParentCount', replacedParentCount); + else + url.searchParams.delete('replacedParentCount'); + + history.replaceState({}, document.title, url.href); + } + + async function init(retryCount = 0) { + if (gTitle && + gTitleField && + gTemporaryCheck && + gTemporaryAggressiveCheck && + gBrowserThemeDefinition && + gUserStyleRules) + return; + + if (retryCount > 10) + throw new Error('could not initialize group tab contents'); + + gTitle = document.querySelector('#title'); + gTitleField = document.querySelector('#title-field'); + gTemporaryCheck = document.querySelector('#temporary'); + gTemporaryAggressiveCheck = document.querySelector('#temporaryAggressive'); + gBrowserThemeDefinition = document.querySelector('#browser-theme-definition'); + gUserStyleRules = document.querySelector('#user-style-rules'); + + if (!gTitle || + !gTitleField || + !gTemporaryCheck || + !gTemporaryAggressiveCheck || + !gBrowserThemeDefinition || + !gUserStyleRules) { + setTimeout(init, 100, retryCount + 1); + return; + } + + gTitle.addEventListener('click', event => { + if (event.button == 0 && + !hasModifier(event)) { + enterTitleEdit(); + event.stopPropagation(); + } + }); + gTitleField.addEventListener('keydown', event => { + // Event.isComposing for the Enter key to finish composition is always + // "false" on keyup, so we need to handle this on keydown. + if (hasModifier(event) || + event.isComposing) + return; + + switch (event.key) { + case 'Escape': + gTitleField.value = gTitle.textContent; + exitTitleEdit(); + break; + + case 'Enter': + setTitle(gTitleField.value); + exitTitleEdit(); + break; + + case 'F2': + event.stopPropagation(); + break; + } + }); + window.addEventListener('mouseup', event => { + const closebox = event.target.closest('li span.closebox'); + if (closebox) { + const tabId = closebox.dataset.tabId; + browser.runtime.sendMessage({ + type: 'ws:remove-tabs-internally', + tabIds: [parseInt(tabId)], + byMouseOperation: true, + keepDescendants: true, + }); + return; + } + + const link = event.target.closest('a, span.link'); + const tabId = link?.dataset?.tabId; + if (tabId) { + event.stopImmediatePropagation(); + event.preventDefault(); + if ((event.button == 0 && isAcceled(event)) || + (event.button == 1 && !hasModifier(event))) { + browser.runtime.sendMessage({ + type: 'ws:remove-tabs-internally', + tabIds: [parseInt(tabId)], + byMouseOperation: true, + keepDescendants: true, + }); + } + else { + browser.runtime.sendMessage({ + type: 'ws:api:focus', + tab: parseInt(tabId), + }); + } + return false; + } + if (event.button != 0 || + hasModifier(event)) + return false; + if (event.target != gTitleField) { + setTitle(gTitleField.value); + exitTitleEdit(); + event.stopPropagation(); + } + }, { useCapture: true }); + window.addEventListener('keyup', event => { + if (event.key == 'F2' && + !hasModifier(event)) + enterTitleEdit(); + }); + + window.addEventListener('resize', reflow); + + gTitle.textContent = gTitleField.value = getTitle(); + + gTemporaryCheck.checked = isTemporary(); + gTemporaryCheck.addEventListener('change', _event => { + if (gTemporaryCheck.checked) + gTemporaryAggressiveCheck.checked = false; + updateParameters(); + }); + + gTemporaryAggressiveCheck.checked = isTemporaryAggressive(); + gTemporaryAggressiveCheck.addEventListener('change', _event => { + if (gTemporaryAggressiveCheck.checked) + gTemporaryCheck.checked = false; + updateParameters(); + }); + + window.setTitle = window.setTitle || setTitle; + window.updateTree = window.updateTree || updateTree; + + window.l10n.updateDocument(); + + const [themeDeclarations, contextualIdentitiesColorInfo, configs, userStyleRules] = await Promise.all([ + browser.runtime.sendMessage({ + type: 'ws:get-theme-declarations' + }), + browser.runtime.sendMessage({ + type: 'ws:get-contextual-identities-color-info' + }), + browser.runtime.sendMessage({ + type: 'ws:get-config-value', + keys: [ + 'renderTreeInGroupTabs', + 'showAutoGroupOptionHint', + 'showAutoGroupOptionHintWithOpener', + 'rtl', + ] + }), + browser.runtime.sendMessage({ + type: 'ws:get-user-style-rules' + }) + ]); + + const contextualIdentitiesMarkerDeclarations = Object.keys(contextualIdentitiesColorInfo.colors).map(id => + `#tabs a[data-cookie-store-id="${id}"] .contextual-identity-marker { + background-color: ${contextualIdentitiesColorInfo.colors[id]}; + }`).join('\n'); + gBrowserThemeDefinition.textContent = ` + ${themeDeclarations} + ${contextualIdentitiesMarkerDeclarations} + ${contextualIdentitiesColorInfo.colorDeclarations} + `; + gUserStyleRules.textContent = userStyleRules; + + document.documentElement.classList.toggle('rtl', configs.rtl); + + updateTree.enabled = configs.renderTreeInGroupTabs; + updateTree(); + + const optionPageSection = getOpenerTabId() ? 'autoGroupNewTabsFromPinned' : 'autoGroupNewTabsSection'; + const optionKey = getOpenerTabId() ? 'showAutoGroupOptionHintWithOpener' : 'showAutoGroupOptionHint'; + let show = configs[optionKey]; + if (!isTemporary() && !isTemporaryAggressive()) + show = false; + + const hint = document.getElementById('optionHint'); + hint.style.display = show ? 'block' : 'none'; + + if (show) { + const uri = `moz-extension://${location.host}/options/options.html#${optionPageSection}`; + hint.firstChild.addEventListener('click', event => { + if (event.button != 0) + return; + browser.runtime.sendMessage({ + type: 'ws:open-tab', + uri, + active: true, + }); + }); + hint.firstChild.addEventListener('keydown', event => { + if (event.key != 'Enter' && + event.key != 'Space') + return; + browser.runtime.sendMessage({ + type: 'ws:open-tab', + uri, + active: true, + }); + }); + + const closebox = document.getElementById('dismissOptionHint'); + closebox.addEventListener('click', event => { + if (event.button != 0) + return; + hint.style.display = 'none'; + browser.runtime.sendMessage({ + type: 'ws:set-config-value', + key: optionKey, + value: false + }); + }); + closebox.addEventListener('keydown', event => { + if (event.key != 'Enter' && + event.key != 'Space') + return; + hint.style.display = 'none'; + browser.runtime.sendMessage({ + type: 'ws:set-config-value', + key: optionKey, + value: false + }); + }); + } + + browser.runtime.onMessage.addListener((message, _sender) => { + switch (message?.type) { + case 'ws:clear-temporary-state': + gTemporaryCheck.checked = gTemporaryAggressiveCheck.checked = false; + updateParameters(); + return Promise.resolve(true); + + case 'ws:replace-state-url': + history.replaceState({}, document.title, message.url); + return Promise.resolve(true); + + case 'ws:update-tree': + updateTree(); + return Promise.resolve(true); + + case 'ws:update-title': + setTitle(message.title); + return Promise.resolve(true); + } + }); + + // Failsafe 2: This is effective on any case, but too slow for newly opened tabs. + document.title = getTitle(); + + document.documentElement.classList.add('initialized'); + } + //document.addEventListener('DOMContentLoaded', init, { once: true }); + + + const loadedIconUrls = new Set(); + const failedIconUrls = new Set(); + + async function updateTree() { + const runAt = updateTree.lastRun = Date.now(); + + const container = document.getElementById('tabs'); + function clear() { + const range = document.createRange(); + range.selectNodeContents(container.firstChild); + range.deleteContents(); + range.detach(); + } + + const rootClassList = document.documentElement.classList; + if (!updateTree.enabled) { + rootClassList.remove('updating'); + clear(); + return; + } + + rootClassList.add('updating'); + + const baseRequest = { + type: 'ws:api:get-tree', + interval: 50, + }; + const [thisTab, openerTab, aliasTab] = await Promise.all([ + // We need to request them separately because + // get-tree always compact the returned array (null tab will be removved). + browser.runtime.sendMessage({ + ...baseRequest, + tab: 'senderTab', + }), + browser.runtime.sendMessage({ + ...baseRequest, + tab: getOpenerTabId(), + }), + browser.runtime.sendMessage({ + ...baseRequest, + tab: getAliasTabId(), + }), + ]); + + /* + console.log('updateTree ', { + ur: location.href, + openerTabId: getOpenerTabId(), + openerTab, + aliasTabId: getAliasTabId(), + aliasTab, + }); + */ + + // called again while waiting + if (runAt != updateTree.lastRun) + return; + + if (!thisTab) { + console.error(new Error('Couldn\'t get tree of tabs unexpectedly.')); + clear(); + rootClassList.remove('updating'); + return; + } + + if (aliasTab) + thisTab.children = aliasTab.children; + + let tree; + if (!aliasTab && openerTab) { + openerTab.children = thisTab.children; + tree = buildChildren({ + children: [ + openerTab, + ], + }); + } + else + tree = buildChildren(thisTab); + + if (tree) { + const { DOMUpdater } = await import('/extlib/dom-updater.js'); + tree.setAttribute('id', 'top-level-tree'); + const newContents = document.createDocumentFragment(); + newContents.appendChild(tree); + DOMUpdater.update(container, newContents); + for (const icon of container.querySelectorAll('li.favicon-loading img')) { + const item = icon.closest('li'); + const url = icon.dataset.faviconUrl; + icon.onerror = () => { + item.classList.remove('favicon-loading'); + item.classList.add('use-default-favicon'); + failedIconUrls.add(url); + }; + icon.onload = () => { + item.classList.remove('favicon-loading'); + loadedIconUrls.add(url); + }; + icon.src = url; + } + reflow(); + } + else { + clear(); + } + + rootClassList.remove('updating'); + rootClassList.add('has-contents'); + } + updateTree.enabled = true; + + function reflow() { + const container = document.getElementById('tabs'); + columnizeTree(container.firstChild, { + columnWidth: 'var(--column-width, 20em)', + containerRect: container.getBoundingClientRect() + }); + } + + function buildItem(tab) { + const item = document.createElement('li'); + item.setAttribute('id', `tab-item-${tab.id}`); + + const link = item.appendChild(document.createElement('span')); + link.setAttribute('id', `tab-link-${tab.id}`); + link.setAttribute('class', 'link'); + link.setAttribute('title', tab.cookieStoreName ? `${tab.title} - ${tab.cookieStoreName}` : tab.title); + link.dataset.tabId = tab.id; + link.dataset.cookieStoreId = tab.cookieStoreId; + + const contextualIdentityMarker = link.appendChild(document.createElement('span')); + contextualIdentityMarker.setAttribute('id', `tab-contextual-identity-marker-${tab.id}`); + contextualIdentityMarker.classList.add('contextual-identity-marker'); + + const defaultFavIcon = link.appendChild(document.createElement('span')); + defaultFavIcon.setAttribute('id', `tab-default-favicon-${tab.id}`); + defaultFavIcon.classList.add('default-favicon'); + + const icon = link.appendChild(document.createElement('img')); + icon.setAttribute('id', `tab-icon-${tab.id}`); + const favIconUrl = tab.effectiveFavIconUrl || tab.favIconUrl; + if (favIconUrl && !failedIconUrls.has(favIconUrl)) { + if (loadedIconUrls.has(favIconUrl)) { + icon.src = favIconUrl; + } + else { + icon.dataset.faviconUrl = favIconUrl; + item.classList.add('favicon-loading'); + } + } + else { + item.classList.add('use-default-favicon'); + } + + if (Array.isArray(tab.states) && + tab.states.includes('group-tab')) + item.classList.add('group-tab'); + + const label = link.appendChild(document.createElement('span')); + label.setAttribute('id', `tab-label-${tab.id}`); + label.classList.add('label'); + label.textContent = tab.title; + + const closeBox = item.appendChild(document.createElement('span')); + closeBox.setAttribute('id', `tab-closebox-${tab.id}`); + closeBox.classList.add('closebox'); + closeBox.dataset.tabId = tab.id; + + const children = buildChildren(tab); + if (!children) + return item; + + children.setAttribute('id', `tab-${tab.id}-children`); + const fragment = document.createDocumentFragment(); + fragment.appendChild(item); + const childrenWrapped = document.createElement('li'); + childrenWrapped.setAttribute('id', `tabr-${tab.id}-children-wrappe`); + childrenWrapped.appendChild(children); + fragment.appendChild(childrenWrapped); + return fragment; + } + + function buildChildren(tab) { + if (tab.children && tab.children.length > 0) { + const list = document.createElement('ul'); + for (const child of tab.children) { + list.appendChild(buildItem(child)); + } + return list; + } + return null; + } + + function columnizeTree(aTree, options) { + options = options || {}; + options.columnWidth = options.columnWidth || 'var(--column-width, 20em)'; + const containerRect = options.containerRect || aTree.parentNode.getBoundingClientRect(); + + const uncolumnizedTree = aTree.cloneNode(true); + const uncolumnizedTreeStyle = uncolumnizedTree.style; + uncolumnizedTreeStyle.visibility = 'hidden'; + uncolumnizedTreeStyle.position = 'absolute'; + uncolumnizedTreeStyle.maxWidth = `${containerRect.width}px`; + uncolumnizedTreeStyle.height = uncolumnizedTreeStyle.maxHeight = ''; + uncolumnizedTreeStyle.columnWidth = ''; + aTree.parentNode.appendChild(uncolumnizedTree); + const totalContentsHeight = uncolumnizedTree.offsetHeight; + aTree.parentNode.removeChild(uncolumnizedTree); + + const style = aTree.style; + if (totalContentsHeight > containerRect.height) { + style.columnWidth = style.MozColumnWidth = `calc(${options.columnWidth})`; + const computedStyle = window.getComputedStyle(aTree, null); + aTree.columnWidth = Number((computedStyle.MozColumnWidth || computedStyle.columnWidth).replace(/px/, '')); + style.columnGap = style.MozColumnGap = '1em'; + style.columnFill = style.MozColumnFill = 'auto'; + style.columnCount = style.MozColumnCount = 'auto'; + const treeContentsRange = document.createRange(); + treeContentsRange.selectNodeContents(aTree); + const overflow = treeContentsRange.getBoundingClientRect().width > window.innerWidth; + treeContentsRange.detach(); + style.maxWidth = ''; + const blankSpace = overflow ? 2 : 1; + style.height = style.maxHeight = + `calc(${containerRect.height}px - ${blankSpace}em)`; + } + else { + style.columnWidth = style.MozColumnWidth = ''; + style.maxWidth = `calc(${containerRect.width}px - 1em /* right-padding of #tabs */ - 20px /* left-margin of the tree */)`; + style.height = style.maxHeight = ''; + } + } + + init(); + return true; +})(); diff --git a/waterfox/browser/components/sidebar/resources/icons/blocked.svg b/waterfox/browser/components/sidebar/resources/icons/blocked.svg new file mode 100644 index 000000000000..e3b775c1a5d5 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/blocked.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/resources/icons/contextual-identities/briefcase.svg b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/briefcase.svg new file mode 100644 index 000000000000..7dc050964c76 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/briefcase.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/contextual-identities/cart.svg b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/cart.svg new file mode 100644 index 000000000000..afc79f4601aa --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/cart.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/contextual-identities/chill.svg b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/chill.svg new file mode 100644 index 000000000000..65cd45a2885f --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/chill.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/contextual-identities/circle.svg b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/circle.svg new file mode 100644 index 000000000000..e3ceed77be7c --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/circle.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/contextual-identities/dollar.svg b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/dollar.svg new file mode 100644 index 000000000000..ac8780134eb2 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/dollar.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/contextual-identities/fence.svg b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/fence.svg new file mode 100644 index 000000000000..d4396cb88123 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/fence.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/contextual-identities/fingerprint.svg b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/fingerprint.svg new file mode 100644 index 000000000000..26926675a150 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/fingerprint.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/contextual-identities/food.svg b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/food.svg new file mode 100644 index 000000000000..cd3558287212 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/food.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/contextual-identities/fruit.svg b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/fruit.svg new file mode 100644 index 000000000000..d4147f1cb7c6 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/fruit.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/contextual-identities/gift.svg b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/gift.svg new file mode 100644 index 000000000000..56029ec67267 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/gift.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/contextual-identities/pet.svg b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/pet.svg new file mode 100644 index 000000000000..4c1899bde5d3 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/pet.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/contextual-identities/tree.svg b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/tree.svg new file mode 100644 index 000000000000..888d98837c1e --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/tree.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/contextual-identities/vacation.svg b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/vacation.svg new file mode 100644 index 000000000000..dfdc9b12ba85 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/contextual-identities/vacation.svg @@ -0,0 +1,14 @@ + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/customize.svg b/waterfox/browser/components/sidebar/resources/icons/customize.svg new file mode 100644 index 000000000000..a280290f08c2 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/customize.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/resources/icons/dashboard.svg b/waterfox/browser/components/sidebar/resources/icons/dashboard.svg new file mode 100644 index 000000000000..e2908d948536 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/dashboard.svg @@ -0,0 +1,4 @@ + + diff --git a/waterfox/browser/components/sidebar/resources/icons/defaultFavicon.svg b/waterfox/browser/components/sidebar/resources/icons/defaultFavicon.svg new file mode 100644 index 000000000000..e1689b5e6d64 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/defaultFavicon.svg @@ -0,0 +1,6 @@ + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/developer.svg b/waterfox/browser/components/sidebar/resources/icons/developer.svg new file mode 100644 index 000000000000..d02257d4d13c --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/developer.svg @@ -0,0 +1,6 @@ + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/device-desktop.svg b/waterfox/browser/components/sidebar/resources/icons/device-desktop.svg new file mode 100644 index 000000000000..deeb20f6214d --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/device-desktop.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/resources/icons/device-mobile.svg b/waterfox/browser/components/sidebar/resources/icons/device-mobile.svg new file mode 100644 index 000000000000..c3798bbfb2f6 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/device-mobile.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/resources/icons/device-tablet.svg b/waterfox/browser/components/sidebar/resources/icons/device-tablet.svg new file mode 100644 index 000000000000..4936f1aa83ad --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/device-tablet.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/resources/icons/device-vr.svg b/waterfox/browser/components/sidebar/resources/icons/device-vr.svg new file mode 100644 index 000000000000..d30e351e7b2f --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/device-vr.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/resources/icons/edit.svg b/waterfox/browser/components/sidebar/resources/icons/edit.svg new file mode 100644 index 000000000000..73057c847187 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/edit.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/resources/icons/experiments.svg b/waterfox/browser/components/sidebar/resources/icons/experiments.svg new file mode 100644 index 000000000000..870914287b51 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/experiments.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/resources/icons/extensionGeneric-16.svg b/waterfox/browser/components/sidebar/resources/icons/extensionGeneric-16.svg new file mode 100644 index 000000000000..8f79707214e3 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/extensionGeneric-16.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/resources/icons/extensions.svg b/waterfox/browser/components/sidebar/resources/icons/extensions.svg new file mode 100644 index 000000000000..4bf376bbae47 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/extensions.svg @@ -0,0 +1,6 @@ + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/folder-16.svg b/waterfox/browser/components/sidebar/resources/icons/folder-16.svg new file mode 100644 index 000000000000..a17ebffcd8f0 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/folder-16.svg @@ -0,0 +1,6 @@ + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/folder.svg b/waterfox/browser/components/sidebar/resources/icons/folder.svg new file mode 100644 index 000000000000..eef51c2d784a --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/folder.svg @@ -0,0 +1,6 @@ + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/globe-16.svg b/waterfox/browser/components/sidebar/resources/icons/globe-16.svg new file mode 100644 index 000000000000..3387136a3c77 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/globe-16.svg @@ -0,0 +1,6 @@ + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/home.svg b/waterfox/browser/components/sidebar/resources/icons/home.svg new file mode 100644 index 000000000000..f933e2bd6873 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/home.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/resources/icons/hourglass.svg b/waterfox/browser/components/sidebar/resources/icons/hourglass.svg new file mode 100644 index 000000000000..4687846c91b7 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/hourglass.svg @@ -0,0 +1,9 @@ + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/info.svg b/waterfox/browser/components/sidebar/resources/icons/info.svg new file mode 100644 index 000000000000..f85c20641718 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/info.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/lockwise.svg b/waterfox/browser/components/sidebar/resources/icons/lockwise.svg new file mode 100644 index 000000000000..884cb63ae477 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/lockwise.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/more-horiz-16.svg b/waterfox/browser/components/sidebar/resources/icons/more-horiz-16.svg new file mode 100644 index 000000000000..efb678465172 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/more-horiz-16.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/resources/icons/performance.svg b/waterfox/browser/components/sidebar/resources/icons/performance.svg new file mode 100644 index 000000000000..f8960495ceeb --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/performance.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/resources/icons/privatebrowsing-favicon.svg b/waterfox/browser/components/sidebar/resources/icons/privatebrowsing-favicon.svg new file mode 100644 index 000000000000..5968f31cb155 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/privatebrowsing-favicon.svg @@ -0,0 +1,10 @@ + + + + + + + + \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/resources/icons/profiler-stopwatch.svg b/waterfox/browser/components/sidebar/resources/icons/profiler-stopwatch.svg new file mode 100644 index 000000000000..c453ff885681 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/profiler-stopwatch.svg @@ -0,0 +1,6 @@ + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/robot.ico b/waterfox/browser/components/sidebar/resources/icons/robot.ico new file mode 100644 index 000000000000..8913387fc93d Binary files /dev/null and b/waterfox/browser/components/sidebar/resources/icons/robot.ico differ diff --git a/waterfox/browser/components/sidebar/resources/icons/settings-photon.svg b/waterfox/browser/components/sidebar/resources/icons/settings-photon.svg new file mode 100644 index 000000000000..67569204f1e3 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/settings-photon.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/resources/icons/settings.svg b/waterfox/browser/components/sidebar/resources/icons/settings.svg new file mode 100644 index 000000000000..8d8f791abef2 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/settings.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/tab-group-chicklet.svg b/waterfox/browser/components/sidebar/resources/icons/tab-group-chicklet.svg new file mode 100644 index 000000000000..5d452e0ca7ab --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/tab-group-chicklet.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/waterfox/browser/components/sidebar/resources/icons/warning.svg b/waterfox/browser/components/sidebar/resources/icons/warning.svg new file mode 100644 index 000000000000..46743bb3ef99 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/warning.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/resources/icons/window.svg b/waterfox/browser/components/sidebar/resources/icons/window.svg new file mode 100644 index 000000000000..251e90758bd1 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/icons/window.svg @@ -0,0 +1,6 @@ + + + + diff --git a/waterfox/browser/components/sidebar/resources/logs.html b/waterfox/browser/components/sidebar/resources/logs.html new file mode 100644 index 000000000000..23dce1cdfa7b --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/logs.html @@ -0,0 +1,20 @@ + + + + + +Logs for Performance Tuning + +

              Query Logs

              +
              [
              +
              
              +
              ];
              +
              
              +
              +

              Internal Messaging Logs

              +
              {
              +
              
              +
              };
              +
              
              diff --git a/waterfox/browser/components/sidebar/resources/module/InContentPanel.js b/waterfox/browser/components/sidebar/resources/module/InContentPanel.js
              new file mode 100644
              index 000000000000..82cf04200b27
              --- /dev/null
              +++ b/waterfox/browser/components/sidebar/resources/module/InContentPanel.js
              @@ -0,0 +1,598 @@
              +/*
              +# 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';
              +
              +// This is the base class of implementations to show custom UI on contents.
              +
              +// This script can be loaded in three ways:
              +//  * REGULAR case:
              +//    loaded into a public webpage
              +//  * SIDEBAR case:
              +//    loaded into the TST sidebar
              +
              +export default class InContentPanel {
              +  static TYPE = 'in-content-panel';
              +  get type() {
              +    return this.constructor.TYPE;
              +  }
              +
              +  panel;
              +  root;
              +  windowId; // for SIDEBAR case
              +
              +  // -moz-platform @media rules looks unavailable on Web contents...
              +  isWindows = /^Win/i.test(navigator.platform);
              +  isLinux = /Linux/i.test(navigator.platform);
              +  isMac = /^Mac/i.test(navigator.platform);
              +
              +  get styleRules() {
              +    return `
              +      .in-content-panel-root {
              +        --in-content-panel-show-hide-animation: opacity 0.1s ease-out;
              +        --in-content-panel-scale: 1; /* Web contents may be zoomed by the user, and we need to cancel the zoom effect. */
              +        --max-32bit-integer: 2147483647;
              +        background: transparent;
              +        border: 0 none;
              +        bottom: auto;
              +        height: 0px;
              +        left: 0;
              +        opacity: 1;
              +        overflow: hidden;
              +        position: fixed;
              +        right: 0;
              +        top: 0;
              +        transition: var(--in-content-panel-show-hide-animation);
              +        width: 100%;
              +        z-index: var(--max-32bit-integer);
              +
              +        .in-content-panel {
              +          /* https://searchfox.org/mozilla-central/rev/dfaf02d68a7cb018b6cad7e189f450352e2cde04/toolkit/themes/shared/popup.css#11-63 */
              +          color-scheme: light dark;
              +
              +          --panel-background: Menu;
              +          --panel-color: MenuText;
              +          --panel-padding-block: calc(4px / var(--in-content-panel-scale));
              +          --panel-padding: var(--panel-padding-block) 0;
              +          --panel-border-radius: calc(4px / var(--in-content-panel-scale));
              +          --panel-border-color: ThreeDShadow;
              +          --panel-width: initial;
              +
              +          --panel-shadow-margin: 0px;
              +          --panel-shadow: 0px 0px var(--panel-shadow-margin) hsla(0,0%,0%,.2);
              +          -moz-window-input-region-margin: var(--panel-shadow-margin);
              +          margin: calc(-1 * var(--panel-shadow-margin));
              +
              +          /* Panel design token theming */
              +          --background-color-canvas: var(--panel-background);
              +
              +          /*@media (-moz-platform: linux) {*/
              +          ${this.isLinux ? '' : '/*'}
              +            --panel-border-radius: calc(8px / var(--in-content-panel-scale));
              +            --panel-padding-block: calc(3px / var(--in-content-panel-scale));
              +
              +            @media (prefers-contrast) {
              +              --panel-border-color: color-mix(in srgb, currentColor 60%, transparent);
              +            }
              +          ${this.isLinux ? '' : '*/'}
              +          /*}*/
              +
              +          /*@media (-moz-platform: linux) or (-moz-platform: windows) {*/
              +          ${this.isLinux || this.isWindows ? '' : '/*'}
              +            --panel-shadow-margin: calc(4px / var(--in-content-panel-scale));
              +          ${this.isLinux || this.isWindows ? '' : '*/'}
              +          /*}*/
              +
              +          /* On some linux WMs we need to draw square menus because alpha is not available */
              +          @media /*(-moz-platform: linux) and*/ (not (-moz-gtk-csd-transparency-available)) {
              +            ${this.isLinux ? '' : '/*'}
              +            --panel-shadow-margin: 0px !important;
              +            --panel-border-radius: 0px !important;
              +            ${this.isLinux ? '' : '*/'}
              +          }
              +
              +          /*@media (-moz-platform: macos) {*/
              +          ${this.isMac ? '' : '/*'}
              +            appearance: auto;
              +            -moz-default-appearance: menupopup;
              +            background-color: Menu;
              +            --panel-background: white /* https://searchfox.org/mozilla-central/rev/86c208f86f35d53dc824f18f8e540fe5b0663870/browser/themes/shared/browser-colors.css#89 https://searchfox.org/mozilla-central/rev/86c208f86f35d53dc824f18f8e540fe5b0663870/toolkit/themes/shared/global-shared.css#128 */;
              +            --panel-border-color: transparent;
              +            --panel-border-radius: calc(6px / var(--in-content-panel-scale));
              +          ${this.isMac ? '' : '*/'}
              +          /*}*/
              +
              +          /* https://searchfox.org/mozilla-central/rev/dfaf02d68a7cb018b6cad7e189f450352e2cde04/browser/themes/shared/tabbrowser/tab-hover-preview.css#5 */
              +          --panel-width: min(100%, calc(${this.BASE_PANEL_WIDTH}px / var(--in-content-panel-scale)));
              +          --panel-padding: 0;
              +
              +          /* https://searchfox.org/mozilla-central/rev/b576bae69c6f3328d2b08108538cbbf535b1b99d/toolkit/themes/shared/global-shared.css#111 */
              +          /* https://searchfox.org/mozilla-central/rev/b576bae69c6f3328d2b08108538cbbf535b1b99d/browser/themes/shared/browser-colors.css#90 */
              +          --panel-border-color: light-dark(rgb(240, 240, 244), rgb(82, 82, 94));
              +
              +
              +          @media (prefers-color-scheme: dark) {
              +            --panel-background: ${this.isMac ? 'rgb(66, 65, 77)' /* https://searchfox.org/mozilla-central/rev/86c208f86f35d53dc824f18f8e540fe5b0663870/browser/themes/shared/browser-colors.css#89 https://searchfox.org/mozilla-central/rev/86c208f86f35d53dc824f18f8e540fe5b0663870/toolkit/themes/shared/global-shared.css#128 */ : 'var(--dark-popup)'};
              +            --panel-color: var(--dark-popup-text);
              +            --panel-border-color: var(--dark-popup-border);
              +          }
              +
              +          background: var(--panel-background);
              +          border: var(--panel-border-color) solid calc(1px / var(--in-content-panel-scale));
              +          border-radius: var(--panel-border-radius);
              +          box-shadow: var(--panel-shadow);
              +          box-sizing: border-box;
              +          color: var(--panel-color);
              +          direction: ltr;
              +          font: Message-Box;
              +          left: auto;
              +          line-height: 1.5;
              +          margin-block-start: 0px;
              +          max-width: var(--panel-width);
              +          min-width: var(--panel-width);
              +          opacity: 0;
              +          padding: 0;
              +          position: fixed;
              +          right: auto;
              +          z-index: var(--max-32bit-integer);
              +
              +          &.rtl {
              +            direction: rtl;
              +          }
              +          &.animation {
              +            transition: var(--in-content-panel-show-hide-animation),
              +                        left 0.1s ease-out,
              +                        margin-block-start 0.1s ease-out,
              +                        right 0.1s ease-out;
              +          }
              +          &.open {
              +            opacity: 1;
              +          }
              +
              +          &.updating,
              +          & .updating {
              +            visibility: hidden;
              +          }
              +        }
              +
              +        .in-content-panel-contents {
              +          max-width: calc(var(--panel-width) - (2px / var(--in-content-panel-scale)));
              +          min-width: calc(var(--panel-width) - (2px / var(--in-content-panel-scale)));
              +        }
              +
              +        .in-content-panel-contents {
              +          max-height: calc(var(--panel-max-height) - (2px / var(--in-content-panel-scale)));
              +        }
              +      }
              +    `;
              +  }
              +
              +  constructor(givenRoot, ...args) {
              +    this.lastTimestamp = 0;
              +    this.lastTimestampFor = new Map();
              +
              +    this.BASE_PANEL_WIDTH  = '280px';
              +
              +    try {
              +      this.init(givenRoot, ...args);
              +
              +      browser.runtime.sendMessage({
              +        type: `ws:${this.type}:ready`,
              +      });
              +    }
              +    catch (error) {
              +      console.log('TST In Content Panel fatal error: ', error);
              +      this.root = this.onMessageSelf = this.destroySelf = null;
              +    }
              +  }
              +  init(givenRoot) { // this can be overridden by subclasses
              +    this.destroySelf = this.destroy.bind(this);
              +    this.onMessageSelf = this.onMessage.bind(this);
              +
              +    this.root = givenRoot || document.documentElement;
              +    this.root.classList.add('in-content-panel-root');
              +
              +    const style = document.createElement('style');
              +    style.setAttribute('type', 'text/css');
              +    style.textContent = this.styleRules;
              +    this.root.appendChild(style);
              +
              +    browser.runtime.onMessage.addListener(this.onMessageSelf);
              +    window.addEventListener('unload', this.destroySelf, { once: true });
              +    window.addEventListener('pagehide', this.destroySelf, { once: true });
              +  }
              +
              +  async onBeforeShow(_message, _sender) {} // this can be overridden by subclasses
              +
              +  onMessage(message, sender) {
              +    if ((this.windowId &&
              +        message?.windowId != this.windowId))
              +      return;
              +
              +    if (message?.logging)
              +      console.log(`${message.type}: `, message);
              +
              +    switch (message?.type) {
              +      case `ws:${this.type}:show`:
              +        return (async () => {
              +          await this.onBeforeShow(message, sender);
              +          if (message.timestamp < this.lastTimestamp ||
              +              message.timestamp < (this.lastTimestampFor.get(message.targetId) || 0)) {
              +            if (message?.logging)
              +              console.log(`${this.type} show ${message.targetId}: expired, give up to show/update `, message.timestamp);
              +            return true;
              +          }
              +          if (message?.logging)
              +            console.log(`${this.type} show ${message.targetId}: invoked, let's show/update `, message.timestamp);
              +          this.lastTimestamp = message.timestamp;
              +          this.lastTimestampFor.set(message.targetId, message.timestamp);
              +          this.prepareUI();
              +          this.updateUI(message);
              +          this.panel.classList.add('open');
              +          return true;
              +        })();
              +
              +      case `ws:${this.type}:hide`:
              +        return (async () => {
              +          // Ensure the order of messages: "show" for new target =>
              +          // "hide" for previous target.
              +          await new Promise(requestAnimationFrame);
              +          if (!this.panel ||
              +              (message.targetId &&
              +               this.panel.dataset.targetId != message.targetId)) {
              +            if (message?.logging)
              +              console.log(`${this.type} hide ${message.targetId}: already hidden, nothing to do `, message.timestamp);
              +            if (!this.panel && !message.targetId) { // on initial case
              +              this.lastTimestamp = message.timestamp;
              +            }
              +            if (message.targetId) {
              +              this.lastTimestampFor.set(message.targetId, message.timestamp);
              +            }
              +            return;
              +          }
              +          if (message.timestamp < this.lastTimestamp ||
              +              (message.targetId &&
              +               message.timestamp < (this.lastTimestampFor.get(message.targetId) || 0))) {
              +            if (message?.logging)
              +              console.log(`${this.type} hide ${message.targetId}: expired, give up to hide `, message.timestamp);
              +            return true;
              +          }
              +          if (message?.logging)
              +            console.log(`${this.type} hide ${message.targetId}: invoked, let's hide  `, message.timestamp);
              +          this.lastTimestamp = message.timestamp;
              +          if (message.targetId) {
              +            this.lastTimestampFor.set(message.targetId, message.timestamp);
              +          }
              +          this.panel.classList.remove('open');
              +          return true;
              +        })();
              +
              +      case 'ws:notify-sidebar-closed':
              +        if (this.panel) {
              +          this.panel.classList.remove('open');
              +        }
              +        break;
              +    }
              +  }
              +
              +  onBeforeDestroy() {} // this can be overridden by subclasses
              +
              +  destroy() {
              +    this.onBeforeDestroy();
              +
              +    if (!this.onMessageSelf)
              +      return;
              +
              +    if (this.panel) {
              +      this.panel.parentNode.removeChild(this.panel);
              +      this.panel = null;
              +    }
              +
              +    browser.runtime.onMessage.removeListener(this.onMessageSelf);
              +    window.removeEventListener('unload', this.destroySelf);
              +    window.removeEventListener('pagehide', this.destroySelf);
              +
              +    this.lastTimestampFor.clear();
              +    this.root = this.onMessageSelf = this.destroySelf = null;
              +  }
              +
              +  get UISource() { // this can be overridden by subclasses
              +    return '';
              +  }
              +
              +  prepareUI() {
              +    if (this.panel) {
              +      return;
              +    }
              +    this.root.insertAdjacentHTML('beforeend', `
              +      
              +
              +
              + ${this.UISource} +
              +
              +
              + `.trim().replace(/>\s+<')); + this.panel = this.root.querySelector('.in-content-panel'); + } + + onUpdateUI() {} // this can be overridden by subclasses + onBeforeCompleteUpdate() {} // this can be overridden by subclasses + onCompleteUpdate() {} // this can be overridden by subclasses + onShown() {} // this can be overridden by subclasses + + updateUI({ targetId, anchorTabRect, offsetTop, align, rtl, scale, logging, animation, backgroundColor, borderColor, color, widthInOuterWorld, fixedOffsetTop, ...params }) { + if (!this.panel) + return; + + const startAt = this.lastStartedAt = Date.now(); + + if (logging) + console.log(`${this.type} updateUI `, { panel: this.panel, targetId, anchorTabRect, offsetTop, align, rtl, scale, widthInOuterWorld, fixedOffsetTop }); + + this.panel.classList.add('updating'); + this.panel.classList.toggle('animation', animation); + + if (backgroundColor) { + this.panel.style.setProperty('--panel-background', backgroundColor); + } + if (borderColor) { + this.panel.style.setProperty('--panel-border-color', borderColor); + } + if (color) { + this.panel.style.setProperty('--panel-color', color); + } + + // This cancels the zoom effect by the user. + // We need to calculate the scale with two devicePixelRatio values + // from both the sidebar and the content area, because all contents + // of the browser window can be scaled on a high-DPI display by the + // platform. + const isResistFingerprintingMode = window.mozInnerScreenY == window.screenY; + const devicePixelRatio = window.devicePixelRatio != 1 ? + window.devicePixelRatio : // devicePixelRatio is always available on macOS with Retina + ((widthInOuterWorld || window.innerWidth) / window.innerWidth); + if (logging) + console.log(`${this.type} updateUI: isResistFingerprintingMode `, isResistFingerprintingMode, { devicePixelRatio }); + // But window.devicePixelRatio is not available if privacy.resistFingerprinting=true, + // thus we need to calculate it based on tabs.Tab.width. + scale = devicePixelRatio * (scale || 1); + this.root.style.setProperty('--in-content-panel-scale', scale); + this.panel.style.setProperty('--panel-width', `min(${window.innerWidth}px, calc(${this.BASE_PANEL_WIDTH} / ${scale}))`); + + const offsetFromWindowEdge = isResistFingerprintingMode ? + 0 : + (window.mozInnerScreenY - window.screenY) * scale; + const sidebarContentsOffset = isResistFingerprintingMode ? + (fixedOffsetTop || 0) : + (offsetTop - offsetFromWindowEdge) / scale; + + if (anchorTabRect) { + const panelTopEdge = this.windowId ? anchorTabRect.bottom : anchorTabRect.top; + const panelBottomEdge = this.windowId ? anchorTabRect.bottom : anchorTabRect.top; + const panelMaxHeight = Math.max(window.innerHeight - panelTopEdge - sidebarContentsOffset, panelBottomEdge); + this.panel.style.maxHeight = `${panelMaxHeight}px`; + this.panel.style.setProperty('--panel-max-height', `${panelMaxHeight}px`); + if (logging) + console.log('updateUI: limit panel height to ', this.panel.style.maxHeight, { anchorTabRect, maxHeight: window.innerHeight, sidebarContentsOffset, offsetFromWindowEdge }); + } + + this.panel.classList.toggle('rtl', !!rtl); + + this.panel.dataset.targetId = targetId; + if (align) + this.panel.dataset.align = align; + + const complete = () => { + if (complete.completed) { + return; + } + + this.onBeforeCompleteUpdate({ logging, complete }); + + if (this.panel.dataset.targetId != targetId || + this.lastStartedAt != startAt) + return; + + if (!anchorTabRect) { + this.panel.classList.remove('updating'); + if (logging) + console.log(`${this.type} updateUI/complete: no tab rect, no need to update the position`); + return; + } + + const panelBox = this.panel.getBoundingClientRect(); + if (!panelBox.height && + complete.retryCount++ < 10) { + if (logging) + console.log(`${this.type} updateUI/complete: panel size is zero, retrying `, complete.retryCount); + requestAnimationFrame(complete); + return; + } + + complete.completed = true; + + this.onCompleteUpdate({ logging }); + + const maxY = window.innerHeight / scale; + const panelHeight = panelBox.height; + + let top; + if (this.windowId) { // in-sidebar + if (logging) + console.log(`${this.type} updateUI/complete: in-sidebar, alignment calculating: `, { half: window.innerHeight, maxY, scale, anchorTabRect }); + if (anchorTabRect.top > (window.innerHeight / 2)) { // align to bottom edge of the tab + top = `${Math.min(maxY, anchorTabRect.bottom / scale) - panelHeight - anchorTabRect.height}px`; + if (logging) + console.log(`${this.type} => align to bottom edge of the tab, top=`, top); + } + else { // align to top edge of the tab + top = `${Math.max(0, anchorTabRect.top / scale) + anchorTabRect.height}px`; + if (logging) + console.log(`${this.type} => align to top edge of the tab, top=`, top); + } + + if (logging) + console.log(`${this.type} => top=`, top); + } + else { // in-content + // We need to shift the position with the height of the sidebar header. + const alignToTopPosition = Math.max(0, anchorTabRect.top / scale) + sidebarContentsOffset; + const alignToBottomPosition = Math.min(maxY, anchorTabRect.bottom + sidebarContentsOffset / scale) - panelHeight; + + if (logging) + console.log(`${this.type} updateUI/complete: in-content, alignment calculating: `, { offsetFromWindowEdge, sidebarContentsOffset, alignToTopPosition, panelHeight, maxY, scale }); + if (alignToTopPosition + panelHeight >= maxY && + alignToBottomPosition >= 0) { // align to bottom edge of the tab + top = `${alignToBottomPosition}px`; + if (logging) + console.log(`${this.type} => align to bottom edge of the tab, top=`, top); + } + else { // align to top edge of the tab + top = `${alignToTopPosition}px`; + if (logging) + console.log(`${this.type} => align to top edge of the tab, top=`, top); + } + } + // updateUI() may be called multiple times for a target tab + // (with/without previewURL), so we should not set positions again + // if not needed. Otherwise the animation may be canceled in middle. + if (top && + this.panel.style.top != top) { + this.panel.style.top = top; + } + + let left, right; + if (align == 'left') { + left = 'var(--panel-shadow-margin)'; + right = ''; + } + else { + left = ''; + right = 'var(--panel-shadow-margin)'; + } + if (this.panel.style.left != left) { + this.panel.style.left = left; + } + if (this.panel.style.right != right) { + this.panel.style.right = right; + } + + this.panel.classList.remove('updating'); + + this.onShown({ logging }); + }; + complete.retryCount = 0; + + const completed = this.onUpdateUI({ + // common args + align, + anchorTabRect, + animation, + backgroundColor, + borderColor, + color, + fixedOffsetTop, + logging, + offsetTop, + rtl, + scale, + widthInOuterWorld, + // calculated values + complete, + // extra args for subclasses + ...params, + }); + if (!completed) { + complete(); + } + } + + // for SIDEBAR case + set windowId(id) { + return this.windowId = id; + } + get windowId() { + return this.windowId; + } + + // for SIDEBAR case + handleMessage(message) { + return this.onMessage(message); + } + + getColors() { + this.prepareUI(); + + const style = window.getComputedStyle(this.panel, null); + try { + // Computed style's colors may be unexpected value if the element + // is not rendered on the screen yet and it has colors for light + // and dark schemes. So we need to get preferred colors manually. + const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + return { + backgroundColor: this.getPreferredColor(style.getPropertyValue('--panel-background'), { isDark }), + borderColor: this.getPreferredColor(style.getPropertyValue('--panel-border-color'), { isDark }), + color: this.getPreferredColor(style.getPropertyValue('--panel-color'), { isDark }), + }; + } + catch(_error) { + } + return { + backgroundColor: style.backgroundColor, + borderColor: style.borderColor, + color: style.color, + }; + } + + // Parse light-dark(, ) and return preferred color + getPreferredColor(color, { isDark } = {}) { + if (!color.startsWith('light-dark(')) + return color; + + const values = []; + let buffer = ''; + let inParenCount = 0; + color = color.substring(11); // remove "light-dark(" prefix + ColorParse: + for (let i = 0, maxi = color.length; i < maxi; i++) { + const character = color.charAt(i); + switch (character) { + case '(': + inParenCount++; + buffer += character; + break; + + case ')': + inParenCount--; + if (inParenCount < 0) { + values.push(buffer); + buffer = ''; + break ColorParse; + } + buffer += character; + break; + + case ',': + if (inParenCount > 0) { + buffer += character; + } + else { + values.push(buffer); + buffer = ''; + } + break; + + default: + buffer += character; + break; + } + } + + if (typeof isDark != 'boolean') + isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + return isDark ? values[1] : values[0]; + } +} diff --git a/waterfox/browser/components/sidebar/resources/module/InContentPanelController.js b/waterfox/browser/components/sidebar/resources/module/InContentPanelController.js new file mode 100644 index 000000000000..5894fe77ea08 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/module/InContentPanelController.js @@ -0,0 +1,606 @@ +/* +# 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'; + +// Overview of the in-content UI implementation: +// +// In-content UI is processed by the combination of this script +// and content scripts. Players are: +// +// * The playground tab (TAB): tab active tab which the in-content UI is +// rendered in. +// * The controller module (CONTROLLER): this class. +// * The playground manager (MANAGER): a small content script injected to the +// playground tab by InContentPanelController#preparePlaygroundTab(). +// * The UI implementation module (IMPL): imported as a class (based on +// `./InContentPanel.js`) and injected to the playground tab by +// InContentPanelController#preparePlaygroundTab(). +// +// When we need to show an in-content UI: +// +// S.1. The CONTROLLER sends a message to the MANAGER in the TAB, like +// "are you already have a playground to embed in-content UI?" +// We move toward if the MANAGER responces like "OK, I'm ready!". +// S.1.1. If no response, the CONTROLLER injects MANAGER and IMPL into +// the TAB and waits until the IMPL respond. +// S.1.2. The MANAGER in the TAB starts to instanciate the IMPL. +// S.1.3. The IMPL responds to the CONTROLLER, like "OK, I'm ready!" +// S.1.3.1. If these operation is not finished until some seconds, the +// CONTROLLER gives up and falls back to the UI in sidebar. +// S.1.4. The CONTROLLER receives the "I'm ready" response from the IMPL +// in the TAB, and moves toward. +// S.2. The CONTROLLER sends a message to show the UI with less delay. +// S.3. The IMPL shows the UI as soon as possible, for better user experience. +// S.4. If there is more UI parts which require longer load time, +// we wait until the required resource is prepared successfully. +// S.5. The CONTROLLER sends a follow-up messaeg to the IMPL to complete the +// UI initialization. +// +// When we need to hide the UI: +// +// H.1. The CONTROLLER sends a message to the MANAGER in the TAB, like +// "are you already prepared as a playground?" +// We move toward if the MANAGER responces like "OK, I'm ready!". +// H.1.1. If no response, the MANAGER gives up to hide the UI. +// We have nothing to do. +// H.2. The CONTROLLER sends a message to hide the UI in the TAB, to the IMPL, +// like "hide the UI" +// H.3. The IMPL hides the panel. + +import { + configs, + shouldApplyAnimation, + isRTL, +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; +import * as Permissions from '/common/permissions.js'; +import * as TabsStore from '/common/tabs-store.js'; + +import { Tab } from '/common/TreeItem.js'; + +import InContentPanel from './InContentPanel.js'; + +export default class InContentPanelController { + constructor({ + // required + type, + UIClass, + inSidebarUI, + initializerCode, + canRenderInSidebar, + canRenderInContent, + shouldFallbackToSidebar, + // optional + logger, + shouldLog, + canSendPossibleExpiredMessage, + }) { + this.type = type; + this.log = logger || ((...messages) => console.log(...messages)); + this.shouldLog = shouldLog; + this.canRenderInSidebar = canRenderInSidebar; + this.canRenderInContent = canRenderInContent; + this.shouldFallbackToSidebar = shouldFallbackToSidebar; + this.canSendPossibleExpiredMessage = canSendPossibleExpiredMessage || (message => message.type != `ws:${this.type}:show`); + this.UIClass = UIClass; + this.inSidebarUI = inSidebarUI; + this.initializerCode = initializerCode; + + browser.tabs.onActivated.addListener(activeInfo => { + const timestamp = Date.now(); + + if (activeInfo.windowId != TabsStore.getCurrentWindowId()) + return; + + this.hideInSidebar({ timestamp }); + this.hideIn(activeInfo.tabId, { timestamp }); + this.hideIn(activeInfo.previousTabId, { timestamp }); + }); + } + + value(property) { + if (typeof property == 'function') { + return property(); + } + return property; + } + + // Generates a custom element name at random. This mainly aims to avoid + // conflicting of custom element names defined by webpage scripts. + // The generated name is user-unfriendly, this aims to guard your privacy. + generateOneTimeCustomElementName() { + const alphabets = 'abcdefghijklmnopqrstuvwxyz'; + const prefix = alphabets[Math.floor(Math.random() * alphabets.length)]; + return prefix + '-' + Date.now() + '-' + Math.round(Math.random() * 65000); + } + + // S.1.1. Injects the MANAGER and IMPL into the TAB + async preparePlaygroundTab(playgroundTabId) { + const playgroundTab = Tab.get(playgroundTabId); + if (!playgroundTab) + return; + + this.log(`preparePlaygroundTab (${this.type}): insert container to the tab contents `, playgroundTab.url); + await browser.tabs.executeScript(playgroundTabId, { + matchAboutBlank: true, + runAt: 'document_start', + code: `(() => { // the MANAGER + const logging = ${!!this.value(this.shouldLog)}; + + ${InContentPanel.toString()} + ${this.UIClass.toString()} + + // We cannot use multiple custom element types with contents scripts - + // otherwise second custom type must fail its construction ("super()" in + // its constructor raises unexpected error), so we just use only one + // custom element type and recycle it for multiple purposes. + window.closedContainerType = window.closedContainerType || '${this.generateOneTimeCustomElementName()}'; + + const version = '${browser.runtime.getManifest().version}'; + if (window.lastClosedContainerVersion && + window.lastClosedContainerVersion != version) { + window.clearClosedContents(); + } + window.lastClosedContainerVersion = version; + + // We cannot undefine custom element types, so we define it just one time. + if (!window.customElements.get(window.closedContainerType)) { + window.closedContentsDestructors = new Set(); + // We use a wrapper custom element to enclose all preview elements + // which can contain privacy information. + // It should guard them from accesses by webpage scripts. + class ClosedContainer extends HTMLElement { + constructor() { + super(); + const shadow = this.attachShadow({ mode: 'closed' }); + window.appendClosedContents = element => shadow.appendChild(element); + window.removeClosedContents = element => shadow.removeChild(element); + window.clearClosedContents = () => { + for (const destructor of window.closedContentsDestructors) { + try { + destructor(); + } + catch(error) { + console.error(error); + } + } + for (const element of shadow.childNodes) { + removeClosedContents(element); + } + closedContentsDestructors.clear(); + lastClosedContainer.parentNode.removeChild(lastClosedContainer); + window.lastClosedContainer = null; + }; + } + } + window.customElements.define(window.closedContainerType, ClosedContainer); + window.destroyClosedContents = destructor => { + try{ + destructor(); + } + catch(error) { + console.error(error); + } + window.closedContentsDestructors.delete(destructor); + if (window.closedContentsDestructors.size > 0) { + return; + } + window.lastClosedContainer.parentNode.removeChild(window.lastClosedContainer); + window.lastClosedContainer = null; + }; + window.createClosedContentsDestructor = (instance, onDestroy) => { + let destructor; + + const onMessage = (message, _sender) => { + switch (message?.type) { + case 'ws:' + instance.type + ':ask-container-ready': + return Promise.resolve(true); // S.1.1. Responds to the CONTROLLER + + case '${Constants.kCOMMAND_NOTIFY_TAB_DETACHED_FROM_WINDOW}': + window.destroyClosedContents(destructor); + break; + } + }; + browser.runtime.onMessage.addListener(onMessage); + + destructor = () => { + const root = instance.root; + UIInstances.delete(instance.type); + instance.destroy(); + onDestroy(); + browser.runtime.onMessage.removeListener(onMessage); + window.removeEventListener('unload', destructor); + window.removeEventListener('pagehide', destructor); + window.removeClosedContents(root); + }; + window.addEventListener('unload', destructor, { once: true }); + window.addEventListener('pagehide', destructor, { once: true }); + + window.closedContentsDestructors.add(destructor); + + return destructor; + }; + } + + if (!window.lastClosedContainer) { + window.lastClosedContainer = document.createElement(window.closedContainerType); + document.documentElement.appendChild(window.lastClosedContainer); + } + + window.UIInstances = window.UIInstances || new Map(); + const oldInstance = UIInstances.get('${this.type}'); + if (oldInstance) { + try { + const root = oldInstance.root; + oldInstance.destroy(); + removeClosedContents(root); + } + catch(_error) { + } + } + + UIInstances.set('${this.type}', (() => { + ${this.initializerCode} + })()); + })()`, + }); + } + + // S.1.4 Wait until "I'm ready" message from the IMPL + async waitUntilPlaygroundTabIsReady(playgroundTabId) { + let resolver; + const promisedLoaded = new Promise((resolve, _reject) => { + resolver = resolve; + }); + let timeout; + const onMessage = (message, sender) => { + if (message?.type != `ws:${this.type}:ready` || + sender.tab?.id != playgroundTabId) + return; + this.log(`waitUntilPlaygroundTabIsReady(${this.type}): ready in the tab `, playgroundTabId); + if (timeout) { + clearTimeout(timeout); + timeout = null; + } + resolver(); + }; + browser.runtime.onMessage.addListener(onMessage); + timeout = setTimeout(() => { + if (!timeout) + return; + this.log(`waitUntilPlaygroundTabIsReady(${this.type}): timeout for the tab `, playgroundTabId); + timeout = null; + browser.runtime.onMessage.removeListener(onMessage); + resolver(); + }, 1000); + return promisedLoaded; + } + + // S.1. - S.5. + // returns succeeded or not (boolean) + async sendMessage(playgroundTabId, message, { promisedMessage, canRenderInSidebar, shouldFallbackToSidebar, deferredResultResolver } = {}) { + if (!playgroundTabId || + !this.value(this.canRenderInContent)) { // in-sidebar mode + if (this.value(canRenderInSidebar || this.canRenderInSidebar)) { + this.log(`sendMessage (${this.type}) (${message.type}): no tab specified or sidebar only mode, fallback to in-sidebar UI`); + return this.sendInSidebarMessage(message, { promisedMessage }); + } + else { + this.log(`sendMessage (${this.type}) (${message.type}): no tab specified or not allowed, cancel`); + return false; + } + } + + const retrying = !!deferredResultResolver; + const playgroundTab = Tab.get(playgroundTabId); + if (!playgroundTab) + return false; + + // S.1. Sends a messaeg to the MANAGER + let rawTab; + try { + const [ready, gotRawTab] = await Promise.all([ + browser.tabs.sendMessage(playgroundTabId, { + type: `ws:${this.type}:ask-container-ready`, + }).catch(_error => {}), + browser.tabs.get(playgroundTabId), + ]); + rawTab = gotRawTab; + this.log(`sendMessage (${this.type}) (${message.type}${retrying ? ', retrying' : ''}): response from the tab: `, { ready }); + if (!ready) { + if (!message.canRetry) { + this.log(`sendMessage (${this.type}) => no response, give up to send`); + return false; + } + + if (retrying) { + // Retried to init tab preview panel, but failed, so + // now we fall back to the in-sidebar tab preview. + if (!this.value(shouldFallbackToSidebar || this.shouldFallbackToSidebar) || + !this.canSendPossibleExpiredMessage(message)) { + this.log(`sendMessage (${this.type}) => no response after retrying, give up to send`); + deferredResultResolver(false); + return false; + } + this.log(`sendMessage (${this.type}) => no response after retrying, fall back to in-sidebar previes`); + return this.sendInSidebarMessage(message, { promisedMessage }) + .then(() => { + deferredResultResolver(true); + return true; + }); + } + + // We prepare tab preview panel now, and retry sending after that. + this.log(`sendMessage (${this.type}) => no response, retry`); + let resultResolver; + const promisedResult = new Promise((resolve, _reject) => { + resultResolver = resolve; + }); + this.waitUntilPlaygroundTabIsReady(playgroundTabId).then(() => { + this.sendMessage(playgroundTabId, message, { + promisedMessage, + canRenderInSidebar, + shouldFallbackToSidebar, + deferredResultResolver: resultResolver, + }); + }); + // S.1.1. Injects the IMPL + await this.preparePlaygroundTab(playgroundTabId); + return promisedResult; + } + } + catch (error) { + this.log(`sendMessage (${this.type}) (${message.type}${retrying ? ', retrying' : ''}): failed to ask to the tab `, error); + // We cannot show in-content UI in a tab with privileged contents. + // Let's fall back to the in-sidebar UI. + await this.sendInSidebarMessage(message, { promisedMessage }); + if (deferredResultResolver) + deferredResultResolver(true); + return true; + } + + // hide in-sidebar tab preview if in-content tab preview is available + this.hideInSidebar(); + + let response; + try { + // S.2. Sends a message to the UI with less delay. + const timestamp = Date.now(); + response = await browser.tabs.sendMessage(playgroundTabId, { + timestamp, + ...message, + ...this.inSidebarUI.getColors(), + widthInOuterWorld: rawTab.width, + fixedOffsetTop: configs.inContentUIOffsetTop, + animation: shouldApplyAnimation(), + logging: this.value(this.shouldLog), + }); + this.log(`sendMessage (${this.type}) (${message.type}${retrying ? ', retrying' : ''}): message was sent, response=`, response, ', promisedMessage =', promisedMessage); + if (deferredResultResolver) + deferredResultResolver(!!response); + + if (response && promisedMessage) { + // S.5. Sends a follow-up message. + this.log(`sendMessage (${this.type}) (${message.type}${retrying ? ', retrying' : ''}, with proimsed properties): trying to wait until promised properties are resolved`); + promisedMessage.then(async resolvedMessage => { + const response = await browser.tabs.sendMessage(playgroundTabId, { + timestamp, + ...message, + ...(resolvedMessage || {}), + ...this.inSidebarUI.getColors(), + widthInOuterWorld: rawTab.width, + fixedOffsetTop: configs.inContentUIOffsetTop, + animation: shouldApplyAnimation(), + logging: this.value(this.shouldLog), + }); + this.log(`sendMessage (${this.type}) (${message.type}${retrying ? ', retrying' : ''}, with previewURL): message was sent again, response = `, response); + }); + } + } + catch (error) { + this.log(`sendMessage (${this.type}) (${message.type}${retrying ? ', retrying' : ''}): failed to send message `, error); + if (!message.canRetry) { + this.log(`sendMessage (${this.type}) => no response, give up to send`); + return false; + } + + if (retrying) { + // Retried to initialize in-content UI, but failed, so + // now we fall back to the in-sidebar UI. + if (!this.value(shouldFallbackToSidebar || this.shouldFallbackToSidebar) || + !this.canSendPossibleExpiredMessage(message)) { + this.log(`sendMessage (${this.type}) => no response after retrying, give up to send`); + deferredResultResolver(false); + return false; + } + this.log(`sendMessage (${this.type}) => no response after retrying, fall back to in-sidebar previes`); + return this.sendInSidebarMessage(message, { promisedMessage }) + .then(() => { + deferredResultResolver(true); + return true; + }); + } + + if (!this.canSendPossibleExpiredMessage(message)) { + this.log(`sendMessage (${this.type}) => no response, already canceled, give up to send`); + return false; + } + + // the panel was destroyed unexpectedly, so we re-prepare it. + this.log(`sendMessage (${this.type}) => no response, retry`); + let resultResolver; + const promisedResult = new Promise((resolve, _reject) => { + resultResolver = resolve; + }); + this.waitUntilPlaygroundTabIsReady(playgroundTabId).then(() => { + this.sendMessage(playgroundTabId, message, { + promisedMessage, + canRenderInSidebar, + shouldFallbackToSidebar, + deferredResultResolver: resultResolver, + }); + }); + await this.preparePlaygroundTab(playgroundTabId); + return promisedResult; + } + + if (typeof response != 'boolean' && + this.canSendPossibleExpiredMessage(message)) { + this.log(`sendMessage (${this.type}) (${message.type}${retrying ? ', retrying' : ''}): got invalid response, fallback to in-sidebar preview`); + // Failed to send message to the in-content UI, so + // now we fall back to the in-sidebar UI. + return this.sendInSidebarMessage(message, { promisedMessage }); + } + + // Everything is OK! + return !!response; + } + + async sendInSidebarMessage(message, { promisedMessage } = {}) { + const timestamp = message.timestamp || Date.now(); + this.log(`sendInSidebarMessage(${message.type}})`); + await this.inSidebarUI.handleMessage({ + timestamp, + ...message, + windowId: TabsStore.getCurrentWindowId(), + animation: shouldApplyAnimation(), + logging: this.value(this.shouldLog), + }); + if (promisedMessage) { + promisedMessage.then(resolvedMessage => { + if (!resolvedMessage) { + return; + } + this.inSidebarUI.handleMessage({ + timestamp, + ...message, + ...resolvedMessage, + windowId: TabsStore.getCurrentWindowId(), + animation: shouldApplyAnimation(), + logging: this.value(this.shouldLog), + }); + }); + } + return true; + } + + async show({ + // required + anchorItem, + targetItem, + // optional + messageParams, + promisedMessageParams, + canRenderInSidebar, + shouldFallbackToSidebar, + timestamp, + }) { + if (!timestamp) { + timestamp = Date.now(); + } + + const activeTab = Tab.getActiveTab(TabsStore.getCurrentWindowId()); + const playgroundTabId = Permissions.canInjectScriptToTabSync(activeTab) ? + activeTab.id : + null; + + const anchorTabRawRect = anchorItem?.$TST.element?.substanceElement?.getBoundingClientRect(); + const anchorTabRect = { + bottom: anchorTabRawRect?.bottom || 0, + height: anchorTabRawRect?.height || 0, + left: anchorTabRawRect?.left || 0, + right: anchorTabRawRect?.right || 0, + top: anchorTabRawRect?.top || 0, + width: anchorTabRawRect?.width || 0, + }; + const prevItem = anchorItem?.$TST.unsafePreviousTab; + if (prevItem?.$TST.states.has(Constants.kTAB_STATE_REMOVING)) { + // When we close a tab by mouse operation and the next tab raises up under the cursor, + // in-content UI rendered in the sidebar positioned based on the anchorItem will cover + // the anchorItem itself because it is still shifted for removing tab under the cursor. + // Thus we calculate the safer anchor coordinates here. + const prevItemRect = prevItem.$TST.previousTab?.$TST.element?.getBoundingClientRect(); + anchorTabRect.top = (prevItemRect?.bottom || 0) + 1; + anchorTabRect.bottom = anchorTabRect.top + anchorTabRect.height; + } + + const mayBeRight = window.mozInnerScreenX - window.screenX > (window.outerWidth - window.innerWidth) / 2; + + this.log(`show (${this.type}, ${targetItem.id}}) [${Date.now() - timestamp}msec from start]: show in ${playgroundTabId || 'sidebar'} `, messageParams); + const succeeded = await this.sendMessage( + playgroundTabId, + { + type: `ws:${this.type}:show`, + targetId: targetItem.id, + ...(messageParams || {}), + anchorTabRect, + /* These information is used to calculate offset of the sidebar header */ + offsetTop: window.mozInnerScreenY - window.screenY, + offsetLeft: window.mozInnerScreenX - window.screenX, + align: mayBeRight ? 'right' : 'left', + rtl: isRTL(), + scale: 1 / window.devicePixelRatio, + // Don't call Date.now() here, because it can become larger than + // the timestamp on mouseleave. + timestamp, + canRetry: !!playgroundTabId, + }, + { + promisedMessage: promisedMessageParams, + canRenderInSidebar, + shouldFallbackToSidebar, + } + ).catch(error => { + this.log(`show (${this.type}$, {targetItem.id}}) failed: `, error); + }); + this.log(` => ${succeeded ? 'succeeded' : 'failed'}`); + return succeeded; + } + + async hide({ timestamp, targetItem } = {}) { + if (!timestamp) { + timestamp = Date.now(); + } + + const activeTab = Tab.getActiveTab(TabsStore.getCurrentWindowId()); + const playgroundTabId = await Permissions.canInjectScriptToTab(activeTab) ? + activeTab.id : + null; + + if (playgroundTabId) { + this.hideIn(playgroundTabId, { timestamp, targetItem }); + } + else { + this.hideInSidebar({ timestamp, targetItem }); + } + } + + async hideIn(playgroundTabId, { timestamp, targetItem } = {}) { + if (!timestamp) { + timestamp = Date.now(); + } + + this.log(`hide (${this.type}) (${targetItem?.id}}) hide UI in ${playgroundTabId} `, timestamp); + this.sendMessage(playgroundTabId, { + type: `ws:${this.type}:hide`, + targetId: targetItem?.id, + timestamp, + }); + } + + async hideInSidebar({ timestamp, targetItem } = {}) { + if (!timestamp) { + timestamp = Date.now(); + } + + this.log(`hide (${this.type}) (${targetItem?.id}}) hide UI in sidebar `, timestamp); + this.sendInSidebarMessage({ + type: `ws:${this.type}:hide`, + targetId: targetItem?.id, + timestamp, + }); + } +} diff --git a/waterfox/browser/components/sidebar/resources/module/TabGroupMenuPanel.js b/waterfox/browser/components/sidebar/resources/module/TabGroupMenuPanel.js new file mode 100644 index 000000000000..79de26b7448f --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/module/TabGroupMenuPanel.js @@ -0,0 +1,635 @@ +/* +# 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'; + +import InContentPanel from './InContentPanel.js'; + +export default class TabGroupMenuPanel extends InContentPanel { + static TYPE = 'tab-group-menu'; + + get styleRules() { + return super.styleRules + ` + .in-content-panel-root.tab-group-menu-panel { + .in-content-panel { + &:not(.open) { + pointer-events: none; + } + + overflow-y: auto; + + /* https://searchfox.org/mozilla-central/rev/126697140e711e04a9d95edae537541c3bde89cc/browser/themes/shared/tabbrowser/tabs.css#1145 */ + + /* https://searchfox.org/mozilla-central/rev/7d73613454bfe426fdceb635b33cd3061a69def4/toolkit/themes/shared/design-system/tokens-shared.css#266 */ + /** Size **/ + --size-item-small: 16px; + --size-item-medium: 28px; + --size-item-large: 32px; + + /* https://searchfox.org/mozilla-central/rev/7d73613454bfe426fdceb635b33cd3061a69def4/toolkit/themes/shared/design-system/tokens-shared.css#271 */ + /** Space **/ + --space-xxsmall: calc(0.5 * var(--space-xsmall)); /* 2px */ + --space-xsmall: 0.267rem; /* 4px */ + --space-small: calc(2 * var(--space-xsmall)); /* 8px */ + --space-medium: calc(3 * var(--space-xsmall)); /* 12px */ + --space-large: calc(4 * var(--space-xsmall)); /* 16px */ + --space-xlarge: calc(6 * var(--space-xsmall)); /* 24px */ + --space-xxlarge: calc(8 * var(--space-xsmall)); /* 32px */ + + /* https://searchfox.org/mozilla-central/rev/126697140e711e04a9d95edae537541c3bde89cc/browser/themes/shared/customizableui/panelUI-shared.css#20 */ + --panel-separator-margin-vertical: 4px; + + /* https://searchfox.org/mozilla-central/rev/126697140e711e04a9d95edae537541c3bde89cc/toolkit/themes/shared/design-system/tokens-shared.css#107 */ + /** Color **/ + --color-blue-20: oklch(83% 0.17 260); + --color-blue-60: oklch(55% 0.24 260); + --color-blue-70: oklch(48% 0.2 260); + --color-blue-80: oklch(41% 0.17 260); + --color-cyan-10: oklch(90% 0.07 205); + --color-cyan-20: oklch(83% 0.11 205); + --color-cyan-30: oklch(76% 0.14 205); + --color-cyan-70: oklch(48% 0.2 205); + --color-gray-05: #fbfbfe; + --color-gray-100: #15141a; + --color-green-20: oklch(83% 0.14 145); + --color-green-70: oklch(48% 0.2 145); + --color-orange-20: oklch(86% 0.14 50); + --color-orange-70: oklch(48% 0.20 50); + --color-pink-20: oklch(83% 0.14 360); + --color-pink-70: oklch(48% 0.2 360); + --color-purple-20: oklch(83% 0.14 315); + --color-purple-70: oklch(48% 0.2 315); + --color-red-20: oklch(83% 0.14 15); + --color-red-70: oklch(48% 0.2 15); + --color-white: #ffffff; + --color-yellow-20: oklch(86% 0.14 90); + --color-yellow-70: oklch(51% 0.23 90); + + /* https://searchfox.org/mozilla-central/rev/126697140e711e04a9d95edae537541c3bde89cc/toolkit/themes/shared/design-system/tokens-platform.css#31 */ + --color-accent-primary: AccentColor; + + /* https://searchfox.org/mozilla-central/rev/126697140e711e04a9d95edae537541c3bde89cc/toolkit/themes/shared/design-system/tokens-shared.css#226 */ + /** Focus Outline **/ + --focus-outline: var(--focus-outline-width) solid var(--focus-outline-color); + --focus-outline-color: var(--color-accent-primary); + --focus-outline-inset: calc(-1 * var(--focus-outline-width)); + --focus-outline-offset: 2px; + --focus-outline-width: 2px; + + /* https://searchfox.org/mozilla-central/rev/126697140e711e04a9d95edae537541c3bde89cc/toolkit/themes/shared/design-system/tokens-shared.css#20 */ + /** Border **/ + --border-color-card: color-mix(in srgb, currentColor 10%, transparent); + --border-color-interactive-hover: var(--border-color-interactive); + --border-color-interactive-active: var(--border-color-interactive); + --border-color-interactive-disabled: var(--border-color-interactive); + --border-radius-circle: 9999px; + --border-radius-small: 4px; + --border-radius-medium: 8px; + --border-width: 1px; + + /* https://searchfox.org/mozilla-central/rev/7d73613454bfe426fdceb635b33cd3061a69def4/browser/themes/shared/tabbrowser/tabs.css#79 */ + --tab-group-color-blue: var(--color-blue-70); + --tab-group-color-blue-invert: var(--color-blue-20); + --tab-group-color-purple: var(--color-purple-70); + --tab-group-color-purple-invert: var(--color-purple-20); + --tab-group-color-cyan: var(--color-cyan-70); + --tab-group-color-cyan-invert: var(--color-cyan-20); + --tab-group-color-orange: var(--color-orange-70); + --tab-group-color-orange-invert: var(--color-orange-20); + --tab-group-color-yellow: var(--color-yellow-70); + --tab-group-color-yellow-invert: var(--color-yellow-20); + --tab-group-color-pink: var(--color-pink-70); + --tab-group-color-pink-invert: var(--color-pink-20); + --tab-group-color-green: var(--color-green-70); + --tab-group-color-green-invert: var(--color-green-20); + --tab-group-color-red: var(--color-red-70); + --tab-group-color-red-invert: var(--color-red-20); + --tab-group-color-gray: #5E6A77; + --tab-group-color-gray-invert: #99A6B4; + + /* https://searchfox.org/mozilla-central/rev/126697140e711e04a9d95edae537541c3bde89cc/toolkit/themes/shared/design-system/tokens-shared.css#286 */ + --text-color-error: light-dark(var(--color-red-70), var(--color-red-20)); + + input[value="blue"] { + --tabgroup-swatch-color: var(--tab-group-color-blue); + --tabgroup-swatch-color-invert: var(--tab-group-color-blue-invert); + } + input[value="purple"] { + --tabgroup-swatch-color: var(--tab-group-color-purple); + --tabgroup-swatch-color-invert: var(--tab-group-color-purple-invert); + } + input[value="cyan"] { + --tabgroup-swatch-color: var(--tab-group-color-cyan); + --tabgroup-swatch-color-invert: var(--tab-group-color-cyan-invert); + } + input[value="orange"] { + --tabgroup-swatch-color: var(--tab-group-color-orange); + --tabgroup-swatch-color-invert: var(--tab-group-color-orange-invert); + } + input[value="yellow"] { + --tabgroup-swatch-color: var(--tab-group-color-yellow); + --tabgroup-swatch-color-invert: var(--tab-group-color-yellow-invert); + } + input[value="pink"] { + --tabgroup-swatch-color: var(--tab-group-color-pink); + --tabgroup-swatch-color-invert: var(--tab-group-color-pink-invert); + } + input[value="green"] { + --tabgroup-swatch-color: var(--tab-group-color-green); + --tabgroup-swatch-color-invert: var(--tab-group-color-green-invert); + } + input[value="red"] { + --tabgroup-swatch-color: var(--tab-group-color-red); + --tabgroup-swatch-color-invert: var(--tab-group-color-red-invert); + } + input[value="grey"] { + --tabgroup-swatch-color: var(--tab-group-color-gray); + --tabgroup-swatch-color-invert: var(--tab-group-color-gray-invert); + } + + /* https://searchfox.org/mozilla-central/rev/126697140e711e04a9d95edae537541c3bde89cc/toolkit/themes/shared/popup.css#63 */ + .in-content-panel-contents-inner-box { + padding: var(--panel-padding); + } + + /* https://searchfox.org/mozilla-central/rev/126697140e711e04a9d95edae537541c3bde89cc/browser/themes/shared/tabbrowser/tabs.css#37 */ + --tab-hover-background-color: color-mix(in srgb, currentColor 11%, transparent); + + /* https://searchfox.org/mozilla-central/rev/126697140e711e04a9d95edae537541c3bde89cc/toolkit/themes/shared/design-system/tokens-brand.css#23 */ + --button-background-color: color-mix(in srgb, currentColor 7%, transparent); + --button-background-color-hover: color-mix(in srgb, currentColor 14%, transparent); + --button-background-color-active: color-mix(in srgb, currentColor 21%, transparent); + --button-text-color: light-dark(var(--color-gray-100), var(--color-gray-05)); + --button-text-color-primary: light-dark(var(--color-white), var(--color-gray-100)); + /* https://searchfox.org/mozilla-central/rev/126697140e711e04a9d95edae537541c3bde89cc/toolkit/themes/shared/design-system/tokens-brand.css#30 */ + --color-accent-primary: light-dark(var(--color-blue-60), var(--color-cyan-30)); + --color-accent-primary-hover: light-dark(var(--color-blue-70), var(--color-cyan-20)); + --color-accent-primary-active: light-dark(var(--color-blue-80), var(--color-cyan-10)); + /* https://searchfox.org/mozilla-central/rev/126697140e711e04a9d95edae537541c3bde89cc/toolkit/themes/shared/design-system/tokens-shared.css#99 */ + --button-text-color-primary-hover: var(--button-text-color-primary); + --button-text-color-primary-active: var(--button-text-color-primary-hover); + --button-text-color-primary-disabled: var(--button-text-color-primary); + + + --panel-width: 22em; + --panel-padding: var(--space-large); + --panel-separator-margin: var(--panel-separator-margin-vertical) 0; + font: menu; + + .panel-header { + min-height: auto; + > h1 { + text-align: center; + font: menu; + font-weight: bold; + + margin-top: 0; + } + } + + hr /*toolbarseparator*/ { + margin-block: var(--space-medium); + border: 1px solid; + border-width: 1px 0 0 0; + opacity: 0.5; + } + + .panel-body { + padding-block: var(--space-medium); + } + + &.tab-group-editor-mode-create .tab-group-edit-mode-only, + &:not(.tab-group-editor-mode-create) .tab-group-create-mode-only { + display: none; + } + + .tab-group-editor-name > label { + display: flex; + flex-direction: column; + > label { + margin-inline: 0; + margin-bottom: var(--space-small); + } + > input[type="text"] { + padding: var(/*--space-medium*/--space-xsmall); + } + } + + .tab-group-editor-swatches { + display: flex; + flex-flow: row nowrap; + justify-content: space-between; + + #tabGroupContextMenuRoot & { + flex-flow: row wrap; + justify-content: flex-start; + } + } + + .tab-group-editor-swatch { + appearance: none; + box-sizing: content-box; + margin: 0; + + font-size: 0; + width: 16px; + height: 16px; + padding: var(--focus-outline-offset); + border: var(--focus-outline-width) solid transparent; + border-radius: var(--border-radius-medium); + background-clip: content-box; + background-color: light-dark(var(--tabgroup-swatch-color), var(--tabgroup-swatch-color-invert)); + + &:checked { + border-color: var(--focus-outline-color); + } + + &:disabled { + opacity: 0.5; + } + + &:focus-visible { + outline: 1px solid var(--focus-outline-color); + outline-offset: 1px; + } + + + .label-text { + font-size: 0; + } + } + + .tab-group-edit-actions, + .tab-group-delete { + padding-block: 0; + > button /*toolbarbutton*/ { + appearance: none; + background: transparent; + border: none; + border-radius: var(--space-xsmall); + display: block; + font: menu; + margin: 0; + padding: var(--space-small); + text-align: start; + width: 100%; + + justify-content: flex-start; + + &:hover { + background-color: var(--tab-hover-background-color); + } + + &:focus { + box-shadow: none; + } + } + } + + /* cancel /resources/base.css */ + input:focus { + box-shadow: none; + } + } + + .tab-group-editor-panel.tab-group-editor-panel-expanded { + --panel-width: 25em; + } + + @media not (prefers-contrast) { + .tabGroupEditor_deleteGroup { + color: var(--text-color-error); + } + } + + .tab-group-create-actions { + text-align: end; + + button { + appearance: none; + border: none; + border-radius: var(--space-xsmall); + margin-inline: var(--space-xsmall); + padding: var(--space-small); + + &.primary { + color: var(--button-text-color-primary); + background-color: var(--color-accent-primary); + &:hover { + color: var(--button-text-color-primary-hover); + background-color: var(--color-accent-primary-hover); + } + &:hover:active, + &[open] { + color: var(--button-text-color-primary-active); + background-color: var(--color-accent-primary-active); + } + } + + &:focus { + box-shadow: none; + } + } + } + } + `; + } + + init(givenRoot, i18n) { + // https://searchfox.org/mozilla-central/source/browser/themes/shared/tabbrowser/tabs.css#1143 + this.BASE_PANEL_WIDTH = '22em'; + + super.init(givenRoot); + + this.onClickSelf = this.onClick.bind(this); + this.onKeyDownSelf = this.onKeyDown.bind(this); + + this.i18n = i18n; + + this.root.classList.add('tab-group-menu-panel'); + } + + onMessage(message, sender) { + if ((this.windowId && + message?.windowId != this.windowId)) + return; + + switch (message?.type) { + default: + return super.onMessage(message, sender); + + case `ws:${this.type}:hide-if-shown`: + if (!this.panel || + (message.targetId && + this.panel.dataset.targetId != message.targetId) || + !this.panel.classList.contains('open')) { + return; + } + return super.onMessage({ + ...message, + type: `ws:${this.type}:hide`, + }, sender); + } + } + + onClick(event) { + event.stopPropagation(); + const command = event.target?.closest('input, button')?.dataset?.command; + if (!command) { + return; + } + browser.runtime.sendMessage({ + type: 'ws:invoke-native-tab-group-menu-panel-command', + windowId: this.windowId, + groupId: parseInt(this.panel.dataset.targetId), + command, + }); + this.onMessage({ + type: `ws:${this.type}:hide`, + windowId: this.windowId, + timestamp: Date.now(), + }); + } + + onKeyDown(event) { + event.stopPropagation(); + const target = event.target?.closest('input, button'); + if (!target) { + return; + } + switch (event.key) { + case 'Tab': + this.advanceFocus(event.shiftKey ? -1 : 1); + event.preventDefault(); + return; + + case 'Enter': + case 'Return': + browser.runtime.sendMessage({ + type: 'ws:invoke-native-tab-group-menu-panel-command', + windowId: this.windowId, + groupId: parseInt(this.panel.dataset.targetId), + command: target.dataset?.command, + }); + this.onMessage({ + type: `ws:${this.type}:hide`, + windowId: this.windowId, + timestamp: Date.now(), + }); + return; + + case 'Escape': + this.onMessage({ + type: `ws:${this.type}:hide`, + windowId: this.windowId, + timestamp: Date.now(), + }); + return; + } + } + + advanceFocus(direction) { + const lastFocused = this.panel.querySelector('input:focus, button:focus'); + const focusibleItems = this.focusibleItems; + const index = lastFocused ? focusibleItems.indexOf(lastFocused) : -1; + const lastIndex = focusibleItems.length - 1; + if (index < 0) { + if (direction < 0) { + this.focusTo(focusibleItems[lastIndex]); + } + else { + this.focusTo(focusibleItems[0]); + } + return; + } + this.focusTo(direction < 0 ? + (index == 0 ? focusibleItems[lastIndex] : focusibleItems[index - 1]) : + (index == lastIndex ? focusibleItems[0] : focusibleItems[index + 1]) + ); + } + + focusTo(item) { + if (!item) { + return; + } + item.focus(); + } + + get focusibleItems() { + return [...this.panel.querySelectorAll('input[type="text"], input[type="radio"]:checked, button')] + .filter(item => item.offsetWidth > 0 && item.offsetHeight > 0); + } + + onBeforeDestroy() { + if (!this.onMessageSelf) + return; + + if (this.panel) { + this.panel.removeEventListener('click', this.onClickSelf); + this.panel.removeEventListener('keydown', this.onKeyDownSelf); + } + + this.onClickSelf = this.onKeyDownSelf = this.i18n = null; + } + + get UISource() { + const i18n = this.i18n; + const doneButton = ` + + `; + const cancelButton = ` + + `; + return ` +
              +
              +

              ${this.sanitizeForHTMLText(i18n.tabGroupMenu_tab_group_editor_title_create)}

              +

              ${this.sanitizeForHTMLText(i18n.tabGroupMenu_tab_group_editor_title_edit)}

              +
              +
              +
              +
              + +
              +
              +
              + + + + + + + + + +
              +
              +
              + + + + +
              +
              +
              + +
              + +
              + ${ this.isWindows ? doneButton + cancelButton : cancelButton + doneButton /* https://searchfox.org/mozilla-central/rev/b7b6aa5e8ffc27bc70d4c129c95adc5921766b93/toolkit/content/widgets/moz-button-group/moz-button-group.mjs#74 */ } +
              +
              + `; + } + sanitizeForHTMLText(text) { + return (text || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + } + + prepareUI() { + if (this.panel) { + return; + } + super.prepareUI(); + + const titleField = this.panel.querySelector('.in-content-panel-title-field'); + titleField.addEventListener('input', event => { + browser.runtime.sendMessage({ + type: 'ws:update-native-tab-group', + groupId: parseInt(this.panel.dataset.targetId), + title: event.target.value, + }); + }); + const colorRadioGroup = this.panel.querySelector('.tab-group-editor-swatches'); + colorRadioGroup.addEventListener('change', event => { + if (!event.target.checked) { + return; + } + browser.runtime.sendMessage({ + type: 'ws:update-native-tab-group', + groupId: parseInt(this.panel.dataset.targetId), + color: event.target.value, + }); + }); + this.panel.addEventListener('click', this.onClickSelf); + this.panel.addEventListener('keydown', this.onKeyDownSelf); + } + + onUpdateUI({ targetId, groupTitle, groupColor, creating, anchorTabRect, logging, complete, ...params }) { + if (logging) + console.log(`${this.type} updateUI `, { panel: this.panel, targetId, groupTitle, groupColor, creating, anchorTabRect, ...params }); + + this.panel.classList.toggle('tab-group-editor-mode-create', creating); + + const titleField = this.panel.querySelector('.in-content-panel-title-field'); + titleField.value = groupTitle || ''; + + const colorRadio = this.panel.querySelector(`.tab-group-editor-swatches input[value="${groupColor}"]`) + if (colorRadio) { + colorRadio.checked = true; + } + + complete(); + } + + onShown() { + const titleField = this.panel.querySelector('.in-content-panel-title-field'); + titleField.focus(); + } +} diff --git a/waterfox/browser/components/sidebar/resources/module/TabPreviewPanel.js b/waterfox/browser/components/sidebar/resources/module/TabPreviewPanel.js new file mode 100644 index 000000000000..0a7e393bdfdf --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/module/TabPreviewPanel.js @@ -0,0 +1,331 @@ +/* +# 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'; + +// This is the main implementation to show the tab preview panel. +// See also: /siedbar/in-content-panel-tooltip.js + +import InContentPanel from './InContentPanel.js'; + +export default class TabPreviewPanel extends InContentPanel { + static TYPE = 'tab-preview'; + + get styleRules() { + return super.styleRules + ` + .in-content-panel-root.tab-preview-panel { + bottom: 0; + height: 100%; + pointer-events: none; + z-index: calc(var(--max-32bit-integer) - 100); /* put preview panel below tab group menu always */ + + &:hover { + opacity: 0; + } + + .in-content-panel { + overflow: hidden; /* clip the preview with the rounded edges */ + pointer-events: none; + + &.extended { + max-width: min(100%, calc(var(--panel-width) * 2)); + } + + &.animation.updating, + &.animation:not(.open) { + margin-block-start: 1ch; /* The native tab preview panel "popups up" on the vertical tab bar. */ + } + /* + &[data-align="left"].updating, + &[data-align="left"]:not(.open) { + left: -1ch !important; + } + &[data-align="right"].updating, + &[data-align="right"]:not(.open) { + right: -1ch !important; + } + */ + + &.extended .in-content-panel-title, + &.extended .in-content-panel-url, + &.extended .in-content-panel-image-container, + &:not(.extended) .in-content-panel-extended-content { + display: none; + } + &.extended .in-content-panel-contents, + &.extended .in-content-panel-contents-inner-box { + max-width: calc(min(100%, calc(var(--panel-width) * 2)) - (2px / var(--in-content-panel-scale))); + } + + &.blank, + & .blank, + &.hidden, + & .hidden { + display: none; + } + + &.loading, + & .loading { + opacity: 0; + } + + &.updating, + & .updating { + visibility: hidden; + } + } + + .in-content-panel-contents-inner-box { + max-width: calc(var(--panel-width) - (2px / var(--in-content-panel-scale))); + min-width: calc(var(--panel-width) - (2px / var(--in-content-panel-scale))); + } + + .in-content-panel.overflow .in-content-panel-contents { + mask-image: linear-gradient(to top, transparent 0, black 2em); + } + + .in-content-panel-title { + font-size: calc(1em / var(--in-content-panel-scale)); + font-weight: bold; + margin: var(--panel-border-radius) var(--panel-border-radius) 0; + max-height: 3em; /* -webkit-line-clamp looks unavailable, so this is a workaround */ + overflow: hidden; + /* text-overflow: ellipsis; */ + -webkit-line-clamp: 2; /* https://searchfox.org/mozilla-central/rev/dfaf02d68a7cb018b6cad7e189f450352e2cde04/browser/themes/shared/tabbrowser/tab-hover-preview.css#15-18 */ + } + + .in-content-panel-url { + font-size: calc(1em / var(--in-content-panel-scale)); + margin: 0 var(--panel-border-radius); + opacity: 0.69; /* https://searchfox.org/mozilla-central/rev/234f91a9d3ebef0d514868701cfb022d5f199cb5/toolkit/themes/shared/design-system/tokens-shared.css#182 */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .in-content-panel-extended-content { + font-size: calc(1em / var(--in-content-panel-scale)); + margin: var(--panel-border-radius); + white-space: pre; + } + + .in-content-panel-image-container { + border-block-start: calc(1px / var(--in-content-panel-scale)) solid var(--panel-border-color); + margin-block-start: 0.25em; + max-height: calc(var(--panel-width) * ${parseInt(this.BASE_PANEL_HEIGHT) / parseInt(this.BASE_PANEL_WIDTH)}); /* use relative value instead of 140px */ + overflow: hidden; + } + + .in-content-panel-image { + max-width: 100%; + opacity: 1; + + .in-content-panel.animation:not(.updating) & { + transition: opacity 0.2s ease-out; + } + + &.loading { + min-height: ${this.BASE_PANEL_HEIGHT}; + } + } + + /* tree */ + .in-content-panel-extended-content { + ul, + ul ul { + margin-block: 0; + margin-inline: 1em 0; + padding: 0; + list-style: disc; + } + + .title-line { + display: flex; + flex-direction: row; + max-width: 100%; + white-space: nowrap; + + &.title { + overflow: hidden; + text-overflow: ellipsis; + } + + .cookieStoreName { + display: flex; + margin-inline-start: 1ch; + + &::before { + content: "- "; + } + } + } + } + } + `; + } + + init(givenRoot) { + // https://searchfox.org/mozilla-central/rev/dfaf02d68a7cb018b6cad7e189f450352e2cde04/browser/themes/shared/tabbrowser/tab-hover-preview.css#5 + this.BASE_PANEL_WIDTH = '280px'; + this.BASE_PANEL_HEIGHT = '140px'; + this.DATA_URI_BLANK_PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIAAAUAAeImBZsAAAAASUVORK5CYII='; + + super.init(givenRoot); + + this.root.classList.add('tab-preview-panel'); + } + + async onBeforeShow(message, _sender) { + // Simulate the behavior: show tab preview panel with delay + // only when the panel is not shown yet. + if (typeof message.waitInitialShowUntil == 'number' && + (!this.panel || + !this.panel.classList.contains('open'))) { + const delay = Math.max(0, message.waitInitialShowUntil - Date.now()); + if (delay > 0) { + await new Promise((resolve, _reject) => { + setTimeout(resolve, delay); + }); + } + } + } + + get UISource() { + return ` +
              +
              +
              +
              + +
              + `; + } + + prepareUI() { + if (this.panel) { + return; + } + super.prepareUI(); + + const preview = this.panel.querySelector('.in-content-panel-image'); + preview.addEventListener('load', () => { + if (preview.src) + preview.classList.remove('loading'); + }); + } + + onUpdateUI({ targetId, title, url, tooltipHtml, hasPreview, previewURL, logging, complete, scale, ...params }) { + if (logging) + console.log(`${this.type} onUpdateUI `, { panel: this.panel, targetId, title, url, tooltipHtml, hasPreview, previewURL, ...params }); + + const hasLoadablePreviewURL = previewURL && /^((https?|moz-extension):|data:image\/[^,]+,.+)/.test(previewURL); + if (previewURL) + hasPreview = hasLoadablePreviewURL; + + const previewImage = this.panel.querySelector('.in-content-panel-image'); + previewImage.classList.toggle('blank', !hasPreview && !hasLoadablePreviewURL); + if (!previewURL || + (previewURL && + previewURL != previewImage.src)) { + previewImage.classList.add('loading'); + previewImage.src = previewURL || this.DATA_URI_BLANK_PNG; + } + + if (tooltipHtml) { + const extendedContent = this.panel.querySelector('.in-content-panel-extended-content'); + extendedContent.innerHTML = tooltipHtml; + this.panel.classList.add('extended'); + } + + if (typeof title == 'string' || + typeof url == 'string') { + const titleElement = this.panel.querySelector('.in-content-panel-title'); + titleElement.textContent = title; + const urlElement = this.panel.querySelector('.in-content-panel-url'); + urlElement.textContent = url; + urlElement.classList.toggle('blank', !url); + this.panel.classList.remove('extended'); + } + + if (!hasPreview) { + if (logging) { + console.log('updateUI: no preview, complete now'); + } + return; + } + + try { + const { width, height } = !previewImage.src || previewImage.src == this.DATA_URI_BLANK_PNG ? + { width: this.BASE_PANEL_WIDTH, height: this.BASE_PANEL_HEIGHT } : + this.getPngDimensionsFromDataUri(previewURL); + if (logging) + console.log('updateUI: determined preview size: ', { width, height }); + const imageWidth = Math.min(window.innerWidth, Math.min(width, parseInt(this.BASE_PANEL_WIDTH)) / scale); + const imageHeight = imageWidth / width * height; + previewImage.style.width = previewImage.style.maxWidth = `min(100%, ${imageWidth}px)`; + previewImage.style.height = previewImage.style.maxHeight = `${imageHeight}px`; + requestAnimationFrame(complete); + return true; + } + catch (error) { + if (logging) + console.log('updateUI: could not detemine preview size ', error, previewURL); + } + + // failsafe: if it is not a png or failed to get dimensions, give up to determine the image size before loading. + previewImage.style.width = + previewImage.style.height = + previewImage.style.maxWidth = + previewImage.style.maxHeight = ''; + previewImage.addEventListener('load', complete, { once: true }); + previewImage.addEventListener('error', complete, { once: true }); + return true; + } + + onBeforeCompleteUpdate({ complete }) { + const previewImage = this.panel.querySelector('.in-content-panel-image'); + previewImage.removeEventListener('load', complete); + previewImage.removeEventListener('error', complete); + } + + onCompleteUpdate({ logging }) { + const panelBox = this.panel.getBoundingClientRect(); + const panelHeight = panelBox.height; + + const contentsHeight = this.panel.querySelector('.in-content-panel-contents-inner-box').getBoundingClientRect().height; + this.panel.classList.toggle('overflow', contentsHeight > panelHeight); + if (logging) + console.log(`${this.type} updateUI/complete: overflow: `, contentsHeight, ' > ', panelHeight); + } + + getPngDimensionsFromDataUri(uri) { + if (!/^data:image\/png;base64,/i.test(uri)) + throw new Error('impossible to parse as PNG image data ', uri); + + const base64Data = uri.split(',')[1]; + const binaryData = atob(base64Data); + const byteArray = new Uint8Array(binaryData.length); + const requiredScanSize = Math.min(binaryData.length, 24); + for (let i = 0; i < requiredScanSize; i++) { + byteArray[i] = binaryData.charCodeAt(i); + } + const pngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + for (let i = 0; i < pngSignature.length; i++) { + if (byteArray[i] !== pngSignature[i]) + throw new Error('invalid PNG header'); + } + const width = + (byteArray[16] << 24) | + (byteArray[17] << 16) | + (byteArray[18] << 8) | + byteArray[19]; + const height = + (byteArray[20] << 24) | + (byteArray[21] << 16) | + (byteArray[22] << 8) | + byteArray[23]; + return { width, height }; + } +} diff --git a/waterfox/browser/components/sidebar/resources/module/logs.js b/waterfox/browser/components/sidebar/resources/module/logs.js new file mode 100644 index 000000000000..61377b5a2c1e --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/module/logs.js @@ -0,0 +1,151 @@ +/* +# 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'; + +import * as Constants from '/common/constants.js'; + +window.addEventListener('DOMContentLoaded', () => { + browser.runtime.onMessage.addListener((message, _sender) => { + if (!message || + typeof message != 'object') + return; + switch (message.type) { + case Constants.kCOMMAND_RESPONSE_QUERY_LOGS: { + if (!message.logs || message.logs.length < 1) + return; + document.getElementById('queryLogs').textContent += message.logs.reduce((output, log, index) => { + if (log) { + log.windowId = message.windowId || 'background'; + output += `${index == 0 ? '' : ',\n'}${JSON.stringify(log)}`; + } + return output; + }, '') + ',\n'; + analyzeQueryLogs(); + }; break; + + case Constants.kCOMMAND_RESPONSE_CONNECTION_MESSAGE_LOGS: { + const logs = document.getElementById('connectionMessageLogs'); + logs.textContent += message.windowId ? `"${message.windowId} => background"` : '"background => sidebar"'; + logs.textContent += ': ' + JSON.stringify(message.logs, null, 2) + ',\n'; + analyzeConnectionMessageLogs(); + }; break; + } + }); + browser.runtime.sendMessage({ type: Constants.kCOMMAND_REQUEST_QUERY_LOGS }); + browser.runtime.sendMessage({ type: Constants.kCOMMAND_REQUEST_CONNECTION_MESSAGE_LOGS }); +}, { once: true }); + +function analyzeQueryLogs() { + const logs = JSON.parse(`[${document.getElementById('queryLogs').textContent.replace(/,\s*$/, '')}]`); + + function toString(data) { + return JSON.stringify(data); + } + + function fromString(data) { + return JSON.parse(data); + } + + const totalElapsedTimes = {}; + function normalize(log) { + const elapsedTime = log.elapsed || log.elasped || 0; + log = fromString(toString(log)); + delete log.elasped; + delete log.elapsed; + delete log.tabs; + log.source = (typeof log.windowId == 'number') ? 'sidebar' : log.windowId; + delete log.windowId; + if (log.indexedTabs) + log.indexedTabs = log.indexedTabs.replace(/\s+in window \d+$/i, ''); + if (log.fromId) + log.fromId = 'given'; + if (log.toId) + log.toId = 'given'; + if (log.fromIndex) + log.fromIndex = 'given'; + if (log.logicalIndex) + log.logicalIndex = 'given'; + for (const key of ['id', '!id']) { + if (log[key]) + log[key] = Array.isArray(log[key]) ? 'array' : (typeof log[key]); + } + const sorted = {}; + for (const key of Object.keys(log).sort()) { + sorted[key] = log[key]; + } + const type = toString(sorted); + const total = totalElapsedTimes[type] || 0; + totalElapsedTimes[type] = total + elapsedTime; + return sorted; + } + const normalizedLogs = logs.map(normalize).map(toString).sort().map(fromString); + + function uniq(logs) { + const logTypes = logs.map(toString); + let lastType; + let lastCount; + const results = []; + for (const type of logTypes) { + if (type != lastType) { + if (lastType) { + results.push({ + count: lastCount, + query: fromString(lastType), + totalElapsed: totalElapsedTimes[lastType] + }); + } + lastType = type; + lastCount = 0; + } + lastCount++; + } + if (lastType) + results.push({ + count: lastCount, + query: fromString(lastType), + totalElapsed: totalElapsedTimes[lastType] + }); + return results; + } + + const results = []; + results.push('Top 10 slowest queries:\n' + logs.sort((a,b) => (b.elasped || b.elapsed || 0) - (a.elasped || a.elapsed || 0)).slice(0, 10).map(toString).join('\n')); + results.push('Count of query tyepes:\n' + uniq(normalizedLogs).sort((a, b) => b.count - a.count).map(toString).join('\n')); + results.push('Sorted in total elapsed time:\n' + uniq(normalizedLogs).sort((a, b) => b.totalElapsed - a.totalElapsed).map(toString).join('\n')); + document.getElementById('queryLogsAnalysis').textContent = '`\n' + results.join('\n') + '\n`'; +} + +function analyzeConnectionMessageLogs() { + const logs = JSON.parse(`{${document.getElementById('connectionMessageLogs').textContent.replace(/,\s*$/, '')}}`); + + const counts = {}; + for (const direction of Object.keys(logs)) { + let partialLogs = logs[direction]; + const isBackground = direction.startsWith('background'); + if (!isBackground) { + partialLogs = {}; + partialLogs[direction] = logs[direction]; + } + for (const part of Object.keys(partialLogs)) { + for (const type of Object.keys(partialLogs[part])) { + const typeLabel = `${type} (${isBackground ? 'background => sidebar' : 'sidebar => background'})`; + counts[typeLabel] = counts[typeLabel] || 0; + counts[typeLabel] += partialLogs[part][type]; + } + } + } + + let totalCount = 0; + const sortableCounts = []; + for (const type of Object.keys(counts)) { + sortableCounts.push({ type, count: counts[type] }); + totalCount += counts[type]; + } + + const results = []; + results.push('Top 10 message types:\n' + sortableCounts.sort((a,b) => b.count- a.count).slice(0, 10).map(count => `${count.type}: ${count.count} (${parseInt(count.count / totalCount * 100)} %)`).join('\n')); + document.getElementById('connectionMessageLogsAnalysis').textContent = '`\n' + results.join('\n') + '\n`'; +} diff --git a/waterfox/browser/components/sidebar/resources/module/protocol-handler.js b/waterfox/browser/components/sidebar/resources/module/protocol-handler.js new file mode 100644 index 000000000000..a99b1df53189 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/module/protocol-handler.js @@ -0,0 +1,55 @@ +/* +# 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'; + +import * as Constants from '/common/constants.js'; + +const uri = decodeURIComponent(location.search.replace(/^\?/, '')); +const matched = uri?.match(Constants.kSHORTHAND_CUSTOM_URI); +if (matched) { + const name = matched[1]; + const params = new URLSearchParams(matched[2] || ''); + const hash = matched[3] || ''; + const delimiter = params.toString() ? '?' : ''; + switch (name.toLowerCase()) { + case 'group': + location.href = `${Constants.kSHORTHAND_URIS.group}${delimiter}${params.toString()}${hash}`; + break; + + case 'startup': + location.href = `${Constants.kSHORTHAND_URIS.startup}${hash}`; + break; + + case 'test-runner': + case 'testrunner': + location.href = `${Constants.kSHORTHAND_URIS.testRunner}${delimiter}${params.toString()}${hash}`; + break; + + case 'options': + location.href = `${Constants.kSHORTHAND_URIS.options}${delimiter}${params.toString()}${hash}`; + break; + + case 'tabbar': + case 'sidebar': + if (!params.has('style')) { + browser.runtime.sendMessage({ + type: 'ws:get-config-value', + keys: ['style'] + }).then(configs => { + params.set('style', configs.style); + location.href = `${Constants.kSHORTHAND_URIS.tabbar}?${params.toString()}${hash}`; + }); + } + else { + location.href = `${Constants.kSHORTHAND_URIS.tabbar}${delimiter}${params.toString()}${hash}`; + } + break; + + case 'forbidden': + location.href = `about:blank?${new URL(location.href).searchParams.get('url') || ''}`; + break; + } +} diff --git a/waterfox/browser/components/sidebar/resources/module/startup.js b/waterfox/browser/components/sidebar/resources/module/startup.js new file mode 100644 index 000000000000..c4668c0bf867 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/module/startup.js @@ -0,0 +1,46 @@ +/* +# 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'; + +import '/extlib/l10n.js'; + +import { + configs, + isRTL, +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; +import * as Permissions from '/common/permissions.js'; + +document.documentElement.classList.toggle('rtl', isRTL()); + +window.addEventListener('DOMContentLoaded', () => { + document.querySelector('#title').textContent = document.title = `${browser.i18n.getMessage('extensionName')} ${browser.runtime.getManifest().version}`; + document.querySelector('#syncTabsToDeviceOptionsLink').href = `${Constants.kSHORTHAND_URIS.options}#syncTabsToDeviceOptions`; + + Permissions.bindToCheckbox( + Permissions.ALL_URLS, + document.querySelector('#allUrlsPermissionGranted'), + { + onChanged: (granted) => { + if (!granted) + return; + configs.tabPreviewTooltip = true; + configs.skipCollapsedTabsForTabSwitchingShortcuts = true; + }, + } + ); + Permissions.bindToCheckbox( + Permissions.BOOKMARKS, + document.querySelector('#bookmarksPermissionGranted') + ); + Permissions.bindToCheckbox( + Permissions.CLIPBOARD_READ, + document.querySelector('#clipboardReadPermissionGranted') + ); + document.querySelector('#clipboardReadPermissionGranted').style.display = configs.middleClickPasteURLOnNewTabButton ? 'none' : ''; + + document.documentElement.classList.add('initialized'); +}, { once: true }); diff --git a/waterfox/browser/components/sidebar/resources/protocol-handler.html b/waterfox/browser/components/sidebar/resources/protocol-handler.html new file mode 100644 index 000000000000..99f8a2745554 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/protocol-handler.html @@ -0,0 +1,7 @@ + + + + + diff --git a/waterfox/browser/components/sidebar/resources/startup.html b/waterfox/browser/components/sidebar/resources/startup.html new file mode 100644 index 000000000000..888d0084c9bc --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/startup.html @@ -0,0 +1,196 @@ + + + + + + + + + +

              __MSG_extensionName__

              +
              +

              __MSG_message_startup_description_1____MSG_message_startup_description_key____MSG_message_startup_description_2____MSG_extensionName____MSG_message_startup_description_3__

              +

              __MSG_message_startup_description_sync_before____MSG_message_startup_description_sync_link____MSG_message_startup_description_sync_after__

              +

              __MSG_config_addons_description_before____MSG_config_addons_description_link_label____MSG_config_addons_description_after__

              +

              __MSG_config_theme_description_before____MSG_config_theme_description_link_label____MSG_config_theme_description_after__

              +

              __MSG_message_startup_history_before____MSG_message_startup_history_link_label____MSG_message_startup_history_after__

              + +

              __MSG_message_startup_requestPermissions_description__

              +
                +
              • +
                  +
                • +
                • +
                • +
                +
              • +
              • +
                  +
                • +
                • +
                • +
                +
              • +
              • +
                  +
                • +
                +
              • +
              + +
              + + +

              __MSG_message_startup_userChromeCss_notify__ +

              __MSG_message_startup_userChromeCss_description_1____MSG_message_startup_userChromeCss_description_link_label____MSG_message_startup_userChromeCss_description_2____MSG_message_startup_userChromeCss_description_note____MSG_message_startup_userChromeCss_description_3__

              +
              diff --git a/waterfox/browser/components/sidebar/resources/ui-base.css b/waterfox/browser/components/sidebar/resources/ui-base.css new file mode 100644 index 000000000000..a993bcbcc3c8 --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/ui-base.css @@ -0,0 +1,30 @@ +/* +# 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 styles for extensions page from chrome://browser/content/extension.css + They are applied to sidebar contents automatically but not provided for + in-page contents, so we manually define them here. +*/ +html, +body { + box-sizing: border-box; + cursor: default; + display: flex; + flex-direction: column; + font: caption; + font-weight: normal; /* This is required to cancel unexpected bold weight produced by the "caption" system font name on Windows 7 Classic theme. See also: https://github.com/piroor/treestyletab/issues/2636 */ + margin-block: 0; + margin-inline: 0; + padding-block: 0; + padding-inline: 0; + -moz-user-select: none; + + * { + box-sizing: border-box; + text-align: start; + } +} diff --git a/waterfox/browser/components/sidebar/resources/ui-color.css b/waterfox/browser/components/sidebar/resources/ui-color.css new file mode 100644 index 000000000000..10cfc0241d6e --- /dev/null +++ b/waterfox/browser/components/sidebar/resources/ui-color.css @@ -0,0 +1,395 @@ +/* +# 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/. +*/ + +:root { + /* https://hg.mozilla.org/mozilla-central/raw-file/tip/toolkit/themes/shared/in-content/common.inc.css */ + --in-content-page-color: var(--grey-90); + --in-content-page-background: var(--grey-10); + --in-content-text-color: var(--in-content-page-color); + --in-content-deemphasized-text: var(--grey-60); + --in-content-selected-text: #fff; + --in-content-box-background: #fff; + --in-content-box-background-hover: var(--grey-20); + --in-content-box-background-active: var(--grey-30); + --in-content-box-border-color: var(--grey-90-a30); + --in-content-box-border-color-mixed: color-mix(in srgb, var(--in-content-box-border-color) 100%, var(--in-content-page-background)); + --in-content-box-info-background: var(--grey-20); + --in-content-item-hover: rgba(69, 161, 255, 0.2); /* blue 40 a20 */ + --in-content-item-hover-mixed: color-mix(in srgb, var(--in-content-item-hover) 100%, var(--in-content-page-background)); + --in-content-item-selected: var(--blue-50); + --in-content-border-highlight: var(--blue-50); + --in-content-border-focus: var(--blue-50); + --in-content-border-hover: var(--grey-90-a50); + --in-content-border-hover-mixed: color-mix(in srgb, var(--in-content-border-hover) 100%, var(--in-content-page-background)); + --in-content-border-active: var(--blue-50); + --in-content-border-active-shadow: var(--blue-50-a30); + --in-content-border-invalid: var(--red-50); + --in-content-border-invalid-shadow: var(--red-50-a30); + --in-content-border-color: var(--grey-30); + --in-content-border-color-mixed: #d7d7db; + --in-content-category-outline-focus: 1px dotted var(--blue-50); + --in-content-category-text-selected: var(--blue-50); + --in-content-category-text-selected-active: var(--blue-60); + --in-content-category-background-hover: rgba(12,12,13,0.1); + --in-content-category-background-active: rgba(12,12,13,0.15); + --in-content-category-background-selected-hover: rgba(12,12,13,0.15); + --in-content-category-background-selected-active: rgba(12,12,13,0.2); + --in-content-tab-color: #424f5a; + --in-content-link-color: var(--blue-60); + --in-content-link-color-hover: var(--blue-70); + --in-content-link-color-active: var(--blue-80); + --in-content-link-color-visited: var(--blue-60); + --in-content-button-background: var(--grey-90-a10); + --in-content-button-background-mixed: color-mix(in srgb, var(--in-content-button-background) 100%, var(--in-content-page-background)); + --in-content-button-background-hover: var(--grey-90-a20); + --in-content-button-background-hover-mixed: color-mix(in srgb, var(--in-content-button-background-hover) 100%, var(--in-content-page-background)); + --in-content-button-background-active: var(--grey-90-a30); + --in-content-button-background-active-mixed: color-mix(in srgb, var(--in-content-button-background-active) 100%, var(--in-content-page-background)); + + --blue-40: #45a1ff; + --blue-40-a10: rgb(69, 161, 255, 0.1); + --blue-50: #0a84ff; + --blue-50-a30: rgba(10, 132, 255, 0.3); + --blue-60: #0060df; + --blue-70: #003eaa; + --blue-80: #002275; + --grey-10: #f9f9fa; + --grey-10-a015: rgba(249, 249, 250, 0.015); + --grey-10-a20: rgba(249, 249, 250, 0.2); + --grey-20: #ededf0; + --grey-30: #d7d7db; + --grey-40: #b1b1b3; + --grey-60: #4a4a4f; + --grey-90: #0c0c0d; + --grey-90-a10: rgba(12, 12, 13, 0.1); + --grey-90-a11: rgba(12, 12, 13, 0.11); + --grey-90-a20: rgba(12, 12, 13, 0.2); + --grey-90-a30: rgba(12, 12, 13, 0.3); + --grey-90-a40: rgba(12, 12, 13, 0.4); + --grey-90-a50: rgba(12, 12, 13, 0.5); + --grey-90-a60: rgba(12, 12, 13, 0.6); + --green-50: #30e60b; + --green-60: #12bc00; + --green-70: #058b00; + --green-80: #006504; + --green-90: #003706; + --orange-50: #ff9400; + --purple-70: #6200a4; + --red-50: #ff0039; + --red-50-a10: rgba(255, 0, 57, 0.1); + --red-50-a30: rgba(255, 0, 57, 0.3); + --red-60: #d70022; + --red-70: #a4000f; + --red-80: #5a0002; + --red-90: #3e0200; + --yellow-10: #ffff98; + --yellow-50: #ffe900; + --yellow-60: #d7b600; + --yellow-60-a30: rgba(215, 182, 0, 0.3); + --yellow-70: #a47f00; + --yellow-80: #715100; + --yellow-90: #3e2800; + + /* https://hg.mozilla.org/mozilla-central/raw-file/tip/browser/themes/addons/dark/manifest.json */ + --dark-frame: #1c1b22; + --dark-icons: rgb(251,251,254); + --dark-ntp-background: #2A2A2E; + --dark-ntp-text: rgb(251, 251, 254); + --dark-popup: rgb(66,65,77); + --dark-popup-border: rgb(82,82,94); + --dark-popup-text: rgb(251,251,254); + --dark-sidebar: #38383D; + --dark-sidebar-text: rgb(249, 249, 250); + --dark-sidebar-border: rgba(255, 255, 255, 0.1); + --dark-tab-background-text: #fbfbfe; + --dark-tab-line: transparent; + --dark-toolbar: rgb(43,42,51); + --dark-toolbar-bottom-separator: hsl(240, 5%, 5%); + --dark-toolbar-field: rgb(28,27,34); + --dark-toolbar-field-border: transparent; + --dark-toolbar-field-separator: #5F6670; + --dark-toolbar-field-text: rgb(251,251,254); + + /* https://searchfox.org/mozilla-central/rev/35873cfc312a6285f54aa5e4ec2d4ab911157522/browser/themes/shared/tabs.inc.css#24 */ + --tab-loading-fill: #0A84FF; + + --focus-outline-color: #0061E0; /* https://searchfox.org/mozilla-central/rev/f60e5304300b286376664b25143cdc6b38a0f0d7/browser/themes/shared/browser-shared.css#124 */ + + /* https://searchfox.org/mozilla-central/rev/7d73613454bfe426fdceb635b33cd3061a69def4/toolkit/themes/shared/design-system/tokens-shared.css#107 */ + --color-blue-0: oklch(97% 0.05 260); + --color-blue-10: oklch(90% 0.13 260); + --color-blue-20: oklch(83% 0.17 260); + --color-blue-30: oklch(76% 0.2 260); + --color-blue-40: oklch(69% 0.22 260); + --color-blue-50: oklch(62% 0.24 260); + --color-blue-60: oklch(55% 0.24 260); + --color-blue-70: oklch(48% 0.2 260); + --color-blue-80: oklch(41% 0.17 260); + --color-blue-90: oklch(34% 0.14 260); + --color-blue-100: oklch(27% 0.1 260); + --color-blue-110: oklch(20% 0.05 260); + --color-cyan-0: oklch(97% 0.05 205); + --color-cyan-10: oklch(90% 0.07 205); + --color-cyan-20: oklch(83% 0.11 205); + --color-cyan-30: oklch(76% 0.14 205); + --color-cyan-40: oklch(69% 0.19 205); + --color-cyan-50: oklch(62% 0.21 205); + --color-cyan-60: oklch(55% 0.21 205); + --color-cyan-70: oklch(48% 0.2 205); + --color-cyan-80: oklch(41% 0.17 205); + --color-cyan-90: oklch(34% 0.14 205); + --color-cyan-100: oklch(27% 0.1 205); + --color-cyan-110: oklch(20% 0.05 205); + --color-gray-05: #fbfbfe; + --color-gray-30: #bac2ca; + --color-gray-50: #bfbfc9; + --color-gray-60: #8f8f9d; + --color-gray-70: #5b5b66; + --color-gray-80: #23222b; + --color-gray-90: #1c1b22; + --color-gray-100: #15141a; + --color-green-0: oklch(97% 0.05 145); + --color-green-10: oklch(90% 0.1 145); + --color-green-20: oklch(83% 0.14 145); + --color-green-30: oklch(76% 0.17 145); + --color-green-40: oklch(69% 0.19 145); + --color-green-50: oklch(62% 0.21 145); + --color-green-60: oklch(55% 0.21 145); + --color-green-70: oklch(48% 0.2 145); + --color-green-80: oklch(41% 0.17 145); + --color-green-90: oklch(34% 0.14 145); + --color-green-100: oklch(27% 0.1 145); + --color-green-110: oklch(20% 0.05 145); + --color-orange-0: oklch(97% 0.05 50); + --color-orange-10: oklch(90% 0.10 50); + --color-orange-20: oklch(86% 0.14 50); + --color-orange-30: oklch(79% 0.17 50); + --color-orange-40: oklch(72% 0.19 50); + --color-orange-50: oklch(65% 0.21 50); + --color-orange-60: oklch(58% 0.21 50); + --color-orange-70: oklch(48% 0.20 50); + --color-orange-80: oklch(41% 0.17 50); + --color-orange-90: oklch(34% 0.14 50); + --color-orange-100: oklch(27% 0.10 50); + --color-orange-110: oklch(20% 0.05 50); + --color-pink-0: oklch(97% 0.05 360); + --color-pink-10: oklch(90% 0.1 360); + --color-pink-20: oklch(83% 0.14 360); + --color-pink-30: oklch(76% 0.17 360); + --color-pink-40: oklch(69% 0.19 360); + --color-pink-50: oklch(62% 0.21 360); + --color-pink-60: oklch(55% 0.21 360); + --color-pink-70: oklch(48% 0.2 360); + --color-pink-80: oklch(41% 0.17 360); + --color-pink-90: oklch(34% 0.14 360); + --color-pink-100: oklch(27% 0.1 360); + --color-pink-110: oklch(20% 0.05 360); + --color-purple-0: oklch(97% 0.05 315); + --color-purple-10: oklch(90% 0.1 315); + --color-purple-20: oklch(83% 0.14 315); + --color-purple-30: oklch(76% 0.17 315); + --color-purple-40: oklch(69% 0.19 315); + --color-purple-50: oklch(62% 0.21 315); + --color-purple-60: oklch(55% 0.21 315); + --color-purple-70: oklch(48% 0.2 315); + --color-purple-80: oklch(41% 0.17 315); + --color-purple-90: oklch(34% 0.14 315); + --color-purple-100: oklch(27% 0.1 315); + --color-purple-110: oklch(20% 0.05 315); + --color-red-0: oklch(97% 0.05 15); + --color-red-10: oklch(90% 0.1 15); + --color-red-20: oklch(83% 0.14 15); + --color-red-30: oklch(76% 0.17 15); + --color-red-40: oklch(69% 0.19 15); + --color-red-50: oklch(62% 0.21 15); + --color-red-60: oklch(55% 0.21 15); + --color-red-70: oklch(48% 0.2 15); + --color-red-80: oklch(41% 0.17 15); + --color-red-90: oklch(34% 0.14 15); + --color-red-100: oklch(27% 0.1 15); + --color-red-110: oklch(20% 0.05 15); + --color-violet-0: oklch(97% 0.05 290); + --color-violet-10: oklch(90% 0.13 290); + --color-violet-20: oklch(83% 0.17 290); + --color-violet-30: oklch(76% 0.2 290); + --color-violet-40: oklch(69% 0.22 290); + --color-violet-50: oklch(62% 0.24 290); + --color-violet-60: oklch(55% 0.24 290); + --color-violet-70: oklch(48% 0.2 290); + --color-violet-80: oklch(41% 0.17 290); + --color-violet-90: oklch(34% 0.14 290); + --color-violet-100: oklch(27% 0.1 290); + --color-violet-110: oklch(20% 0.05 290); + --color-white: #ffffff; + --color-yellow-0: oklch(97% 0.05 90); + --color-yellow-10: oklch(93% 0.1 90); + --color-yellow-20: oklch(86% 0.14 90); + --color-yellow-30: oklch(79% 0.2 90); + --color-yellow-40: oklch(72% 0.22 90); + --color-yellow-50: oklch(65% 0.24 90); + --color-yellow-60: oklch(58% 0.24 90); + --color-yellow-70: oklch(51% 0.23 90); + --color-yellow-80: oklch(41% 0.2 90); + --color-yellow-90: oklch(34% 0.17 90); + --color-yellow-100: oklch(27% 0.13 90); + --color-yellow-110: oklch(20% 0.08 90); + + /* https://searchfox.org/mozilla-central/rev/7d73613454bfe426fdceb635b33cd3061a69def4/browser/themes/shared/tabbrowser/tabs.css#79 */ + --tab-group-color-blue: var(--color-blue-70); + --tab-group-color-blue-invert: var(--color-blue-20); + --tab-group-color-blue-pale: var(--color-blue-0); + --tab-group-color-purple: var(--color-purple-70); + --tab-group-color-purple-invert: var(--color-purple-20); + --tab-group-color-purple-pale: var(--color-purple-0); + --tab-group-color-cyan: var(--color-cyan-70); + --tab-group-color-cyan-invert: var(--color-cyan-20); + --tab-group-color-cyan-pale: var(--color-cyan-0); + --tab-group-color-orange: var(--color-orange-70); + --tab-group-color-orange-invert: var(--color-orange-20); + --tab-group-color-orange-pale: var(--color-orange-0); + --tab-group-color-yellow: var(--color-yellow-70); + --tab-group-color-yellow-invert: var(--color-yellow-20); + --tab-group-color-yellow-pale: var(--color-yellow-0); + --tab-group-color-pink: var(--color-pink-70); + --tab-group-color-pink-invert: var(--color-pink-20); + --tab-group-color-pink-pale: var(--color-pink-0); + --tab-group-color-green: var(--color-green-70); + --tab-group-color-green-invert: var(--color-green-20); + --tab-group-color-green-pale: var(--color-green-0); + --tab-group-color-red: var(--color-red-70); + --tab-group-color-red-invert: var(--color-red-20); + --tab-group-color-red-pale: var(--color-red-0); + --tab-group-color-grey: #5E6A77; + --tab-group-color-grey-invert: #99A6B4; + --tab-group-color-grey-pale: #F2F9FF; + --tab-group-label-text-dark: var(--color-gray-100); + + + --bg-color: var(--grey-10); + --text-color: var(--grey-90); + + + /* simulating deprecated CSS system colors */ + --ThreeDShadow: color-mix(in srgb, ButtonText 30%, ButtonFace); + --ThreeDHighlight: color-mix(in srgb, ButtonText 5%, ButtonFace); + --AppWorkspace: color-mix(in srgb, CanvasText 20%, Canvas); + --MenuText: color-mix(in srgb, ButtonText 85%, ButtonFace); + --Menu: ButtonFace; +} + +:link { + color: var(--in-content-link-color); + + &:hover { + color: var(--in-content-link-color-hover); + } + &:active { + color: var(--in-content-link-color-active); + } +} +:visited { + color: var(--in-content-link-color-visited); + + &:hover { + color: var(--in-content-link-color-hover); + } + &:active { + color: var(--in-content-link-color-active); + } +} + +textarea, +input { + background: var(--in-content-box-background); + border: thin solid var(--in-content-box-border-color-mixed); + color: var(--in-content-text-color); + + &:hover { + border-color: var(--in-content-border-hover-mixed); + } + &:focus { + border-color: var(--in-content-border-focus); + box-shadow: 0 0 0 1px var(--in-content-border-active), + 0 0 0 4px var(--in-content-border-active-shadow); + } +} + +button, +select { + background: var(--in-content-button-background-mixed); + border: 0 none transparent; + color: var(--in-content-text-color); + + &:hover { + background: var(--in-content-button-background-hover-mixed); + } + &:focus { + background: var(--in-content-button-background-active-mixed); + box-shadow: 0 0 0 1px var(--in-content-border-active), + 0 0 0 4px var(--in-content-border-active-shadow); + } +} + +option { + background: transparent /* var(--in-content-button-background-mixed) */; + color: var(--text-color); + + &:active, + &:focus { + background: var(--in-content-item-selected); + } + &:hover { + background: var(--in-content-item-hover-mixed); + } +} + +fieldset, +hr { + border: thin solid var(--in-content-box-border-color-mixed); +} + +hr { + border-width: thin 0 0 0; +} + +@media (prefers-color-scheme: dark) { + :root.sidebar, + :root:not(.sidebar) { + /* https://hg.mozilla.org/mozilla-central/raw-file/tip/toolkit/themes/shared/in-content/common.inc.css */ + --in-content-page-background: #2A2A2E /* rgb(42, 42, 46) */; + --in-content-page-color: rgb(249, 249, 250); + --in-content-text-color: var(--in-content-page-color); + --in-content-deemphasized-text: var(--grey-40); + --in-content-box-background: #202023; + --in-content-box-background-hover: color-mix(in srgb, rgba(249,249,250,0.15) 100%, var(--in-content-page-background)); + --in-content-box-background-active: color-mix(in srgb, rgba(249,249,250,0.2) 100%, var(--in-content-page-background)); + --in-content-box-background-odd: rgba(249,249,250,0.05); + --in-content-box-info-background: rgba(249,249,250,0.15); + + --in-content-border-color: rgba(249,249,250,0.2); + --in-content-border-color-mixed: color-mix(in srgb, var(--in-content-border-color) 100%, var(--in-content-page-background)); + --in-content-border-hover: rgba(249,249,250,0.3); + --in-content-border-hover-mixed: color-mix(in srgb, var(--in-content-border-hover) 100%, var(--in-content-page-background)); + --in-content-box-border-color: rgba(249,249,250,0.2); + --in-content-box-border-color-mixed: color-mix(in srgb, var(--in-content-box-border-color) 100%, var(--in-content-page-background)); + + --in-content-button-background: rgba(249,249,250,0.1); + --in-content-button-background-mixed: color-mix(in srgb, var(--in-content-button-background) 100%, var(--in-content-page-background)); + --in-content-button-background-hover: rgba(249,249,250,0.15); + --in-content-button-background-hover-mixed: color-mix(in srgb, var(--in-content-button-background-hover) 100%, var(--in-content-page-background)); + --in-content-button-background-active: rgba(249,249,250,0.2); + --in-content-button-background-active-mixed: color-mix(in srgb, var(--in-content-button-background-active) 100%, var(--in-content-page-background)); + + --in-content-link-color: var(--blue-40); + --in-content-link-color-hover: var(--blue-50); + --in-content-link-color-active: var(--blue-60); + + --focus-outline-color: #00DDFF; /* https://searchfox.org/mozilla-central/rev/f60e5304300b286376664b25143cdc6b38a0f0d7/browser/themes/shared/browser-shared.css#129 */ + + --bg-color: var(--in-content-page-background); + --text-color: var(--in-content-text-color); + } +} diff --git a/waterfox/browser/components/sidebar/sidebar/background-connection.js b/waterfox/browser/components/sidebar/sidebar/background-connection.js new file mode 100644 index 000000000000..ec30294f57ca --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/background-connection.js @@ -0,0 +1,170 @@ +/* +# 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'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; + +import { + log as internalLogger, + mapAndFilterUniq, + configs +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TSTAPI from '/common/tst-api.js'; + +function log(...args) { + internalLogger('sidebar/background-connection', ...args); +} + +export const onMessage = new EventListenerManager(); + +let mConnectionPort = null; +let mHeartbeatTimer = null; + +export function connect() { + if (mConnectionPort) + return; + const type = /windowId=([1-9][0-9]*)/i.test(location.search) ? 'unknown' : 'sidebar'; + mConnectionPort = browser.runtime.connect({ + name: `${Constants.kCOMMAND_REQUEST_CONNECT_PREFIX}${TabsStore.getCurrentWindowId()}:${type}` + }); + mConnectionPort.onMessage.addListener(onConnectionMessage); + mConnectionPort.onDisconnect.addListener(() => { + log(`Disconnected accidentally: try to reconnect.`); + location.reload(); + }); + if (mHeartbeatTimer) + clearInterval(mHeartbeatTimer); + mHeartbeatTimer = setInterval(() => { + sendMessage({ + type: Constants.kCONNECTION_HEARTBEAT + }); + }, configs.heartbeatInterval); +} + +let mPromisedStartedResolver; +let mPromisedStarted = new Promise((resolve, _reject) => { + mPromisedStartedResolver = resolve; +}); + +export function start() { + if (!mPromisedStartedResolver) + return; + mPromisedStartedResolver(); + mPromisedStartedResolver = undefined; + mPromisedStarted = undefined; +} + +export const counts = {}; + +let mReservedMessages = []; +let mOnFrame; + +export function sendMessage(message) { + if (configs.loggingConnectionMessages) { + counts[message.type] = counts[message.type] || 0; + counts[message.type]++; + } + // We should not send messages immediately, instead we should throttle + // it and bulk-send multiple messages, for better user experience. + // Sending too much messages in one event loop may block everything + // and makes Firefox like frozen. + // + // Heartbeats, however, are the one exception. They run constantly, even + // in an idle browser. Consequently, they should be computationally cheap. + // Moreover, they run in a predictable pattern with plenty of time in between + // so we can be fairly certain they won't cause the US to freeze. + // + // Processing an individual heartbeat message in the general batch message + // flow is inefficient, boxing the single message into an array and using + // iterators to process the list unnecessarily. + if (message.type == Constants.kCONNECTION_HEARTBEAT) { + mConnectionPort.postMessage(message); + return; + } + + mReservedMessages.push(message); + if (!mOnFrame) { + mOnFrame = () => { + mOnFrame = null; + const messages = mReservedMessages; + mReservedMessages = []; + mConnectionPort.postMessage(messages); + if (configs.debug) { + const types = mapAndFilterUniq(messages, + message => message.type || undefined).join(', '); + log(`${messages.length} messages sent (${types}):`, messages); + } + }; + // Because sidebar is always visible, we may not need to avoid using + // window.requestAnimationFrame. + window.requestAnimationFrame(mOnFrame); + } +} + +async function onConnectionMessage(message) { + if (Array.isArray(message)) { + for (const oneMessage of message) { + onConnectionMessage(oneMessage); + } + return; + } + + switch (message.type) { + case 'echo': // for testing + mConnectionPort.postMessage(message); + break; + + case 'external': + TSTAPI.onMessageExternal.dispatch(message.message, message.sender); + break; + + default: + if (mPromisedStarted) + await mPromisedStarted; + onMessage.dispatch(message); + break; + } +} + + +// Mechanism to apply only most recently notified message. +// See also: https://github.com/piroor/treestyletab/issues/2568#issuecomment-657188062 + +const mBufferedMessages = new Map(); + +export function handleBufferedMessage(message, key) { + const bufferKey = `${message.type}:${key}`; + const hasLastMessage = mBufferedMessages.has(bufferKey); + mBufferedMessages.set(bufferKey, message); + return hasLastMessage; +} + +export function fetchBufferedMessage(type, key) { + const bufferKey = `${type}:${key}`; + const message = mBufferedMessages.get(bufferKey); + mBufferedMessages.delete(bufferKey); + return message; +} + + +//=================================================================== +// Logging +//=================================================================== + +browser.runtime.onMessage.addListener((message, _sender) => { + if (!message || + typeof message != 'object' || + message.type != Constants.kCOMMAND_REQUEST_CONNECTION_MESSAGE_LOGS) + return; + + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_RESPONSE_CONNECTION_MESSAGE_LOGS, + logs: JSON.parse(JSON.stringify(counts)), + windowId: TabsStore.getCurrentWindowId() + }); +}); diff --git a/waterfox/browser/components/sidebar/sidebar/cache-storage.js b/waterfox/browser/components/sidebar/sidebar/cache-storage.js new file mode 100644 index 000000000000..997a5dd1c030 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/cache-storage.js @@ -0,0 +1,201 @@ +/* +# 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'; + +import { Tab } from '/common/TreeItem.js'; + +const DB_NAME = 'SidebarStorage'; +const DB_VERSION = 1; +const EXPIRATION_TIME_IN_MSEC = 7 * 24 * 60 * 60 * 1000; // 7 days + +export const PREVIEW = 'previewCaches'; + +let mOpenedDB; + +async function openDB() { + if (mOpenedDB) + return mOpenedDB; + return new Promise((resolve, _reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onerror = () => { + // This can fail if this is in a private window. + // See: https://github.com/piroor/treestyletab/issues/3387 + //reject(new Error('Failed to open database')); + resolve(null); + }; + + request.onsuccess = () => { + const db = request.result; + mOpenedDB = db; + resolve(db); + }; + + request.onupgradeneeded = (event) => { + const db = event.target.result; + if (event.oldVersion < DB_VERSION) { + try { + db.deleteObjectStore(PREVIEW); + } + catch(_error) { + } + + const previewCacheStore = db.createObjectStore(PREVIEW, { keyPath: 'tabId', unique: true }); + previewCacheStore.createIndex('timestamp', 'timestamp'); + } + }; + }); +} + +export async function setValue({ tabId, value, store } = {}) { + const [db, uniqueId] = await Promise.all([ + openDB(), + Tab.get(tabId)?.$TST.promisedUniqueId, + ]); + if (!db || !uniqueId || !uniqueId.id) + return; + + reserveToExpireOldEntries(); + + const timestamp = Date.now(); + try { + const transaction = db.transaction([store], 'readwrite'); + const cacheStore = transaction.objectStore(store); + + cacheStore.put({ + tabId: uniqueId.id, + value, + timestamp, + }); + + transaction.oncomplete = () => { + //db.close(); + tabId = undefined; + value = undefined; + store = undefined; + }; + } + catch(error) { + console.error(`Failed to store cached value for ${uniqueId.id} in the store ${store}`, error); + } +} + +export async function deleteValue({ tabId, store } = {}) { + const [db, uniqueId] = await Promise.all([ + openDB(), + Tab.get(tabId)?.$TST.promisedUniqueId, + ]); + if (!db || !uniqueId || !uniqueId.id) + return; + + reserveToExpireOldEntries(); + + try { + const transaction = db.transaction([store], 'readwrite'); + const cacheStore = transaction.objectStore(store); + cacheStore.delete(uniqueId.id); + transaction.oncomplete = () => { + //db.close(); + tabId = undefined; + store = undefined; + }; + } + catch(error) { + console.error(`Failed to delete cached value for ${uniqueId.id} in the store ${store}`, error); + } +} + +export async function getValue({ tabId, store } = {}) { + return new Promise(async (resolve, _reject) => { + const [db, uniqueId] = await Promise.all([ + openDB(), + Tab.get(tabId)?.$TST.promisedUniqueId, + ]); + if (!db || !uniqueId || !uniqueId.id) { + resolve(null); + return; + } + + const timestamp = Date.now(); + try { + const transaction = db.transaction([store], 'readwrite'); + const cacheStore = transaction.objectStore(store); + const cacheRequest = cacheStore.get(uniqueId.id); + + cacheRequest.onsuccess = () => { + const cache = cacheRequest.result; + if (!cache) { + resolve(null); + return; + } + // IndexedDB does not support partial update, so + // we need to put all properties not only timestamp. + cacheStore.put({ + tabId: uniqueId.id, + value: cache.value, + timestamp, + }); + resolve(cache.value); + cache.tabId = undefined; + cache.value = undefined; + }; + + transaction.oncomplete = () => { + //db.close(); + tabId = undefined; + store = undefined; + }; + } + catch(error) { + console.error('Failed to get from cached value:', error); + resolve(null); + } + }); +} + +async function reserveToExpireOldEntries() { + if (reserveToExpireOldEntries.reservedExpiration) + clearTimeout(reserveToExpireOldEntries.reservedExpiration); + reserveToExpireOldEntries.reservedExpiration = setTimeout(() => { + reserveToExpireOldEntries.reservedExpiration = null; + expireOldEntries(); + }, 500); +} + +async function expireOldEntries() { + return new Promise(async (resolve, reject) => { + const db = await openDB(); + if (!db) { + resolve(); + return; + } + + try { + const transaction = db.transaction([PREVIEW], 'readwrite'); + const previewCacheStore = transaction.objectStore(PREVIEW); + const previewCacheIndex = previewCacheStore.index('timestamp'); + const expirationTimestamp = Date.now() - EXPIRATION_TIME_IN_MSEC; + const previewCacheRequest = previewCacheIndex.openCursor(IDBKeyRange.upperBound(expirationTimestamp)); + previewCacheRequest.onsuccess = (event) => { + const cursor = event.target.result; + if (!cursor) + return; + const key = cursor.primaryKey; + cursor.continue(); + previewCacheStore.delete(key); + }; + + transaction.oncomplete = () => { + //db.close(); + resolve(); + }; + } + catch(error) { + console.error('Failed to expire old entries:', error); + reject(error); + } + }); +} diff --git a/waterfox/browser/components/sidebar/sidebar/collapse-expand.js b/waterfox/browser/components/sidebar/sidebar/collapse-expand.js new file mode 100644 index 000000000000..8fbb2a6e9caa --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/collapse-expand.js @@ -0,0 +1,259 @@ +/* ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is the Tree Style Tab. + * + * The Initial Developer of the Original Code is YUKI "Piro" Hiroshi. + * Portions created by the Initial Developer are Copyright (C) 2011-2024 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): YUKI "Piro" Hiroshi + * wanabe + * Tetsuharu OHZEKI + * Xidorn Quan (Firefox 40+ support) + * lv7777 (https://github.com/lv7777) + * + * ***** END LICENSE BLOCK ******/ +'use strict'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; + +import { + log as internalLogger, + configs, + shouldApplyAnimation, +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; +import * as TabsStore from '/common/tabs-store.js'; + +import { Tab } from '/common/TreeItem.js'; + +import * as BackgroundConnection from './background-connection.js'; + +import { TabInvalidationTarget } from './components/TreeItemElement.js'; + +function log(...args) { + internalLogger('sidebar/collapse-expand', ...args); +} + +export const onUpdating = new EventListenerManager(); +export const onUpdated = new EventListenerManager(); +export const onReadyToExpand = new EventListenerManager(); + +export async function setCollapsed(tab, info = {}) { + log('setCollapsed ', tab.id, info); + if (!TabsStore.ensureLivingItem(tab)) // do nothing for closed tab! + return; + + const changed = ( + info.collapsed != tab.$TST.collapsed || + info.collapsed != tab.$TST.collapsedCompletely + ); + + tab.$TST.shouldExpandLater = false; // clear flag + + if (info.collapsed) { + tab.$TST.addState(Constants.kTAB_STATE_COLLAPSED); + TabsStore.removeVisibleTab(tab); + TabsStore.removeExpandedTab(tab); + } + else { + if (tab.$TST.states.has(Constants.kTAB_STATE_COLLAPSED_DONE)) { + tab.$TST.removeState(Constants.kTAB_STATE_COLLAPSED_DONE); + TabsStore.updateVirtualScrollRenderabilityIndexForTab(tab); + await onReadyToExpand.dispatch(tab); + } + tab.$TST.removeState(Constants.kTAB_STATE_COLLAPSED); + TabsStore.addVisibleTab(tab); + TabsStore.addExpandedTab(tab); + } + + if (tab.$TST.onEndCollapseExpandAnimation) { + clearTimeout(tab.$TST.onEndCollapseExpandAnimation.timeout); + delete tab.$TST.onEndCollapseExpandAnimation; + } + + if (tab.status == 'loading') + tab.$TST.addState(Constants.kTAB_STATE_THROBBER_UNSYNCHRONIZED); + + const manager = tab.$TST.collapsedStateChangedManager || new EventListenerManager(); + + if (tab.$TST.updatingCollapsedStateCanceller) { + tab.$TST.updatingCollapsedStateCanceller(tab.$TST.collapsed); + delete tab.$TST.updatingCollapsedStateCanceller; + } + + let cancelled = false; + const canceller = (aNewToBeCollapsed) => { + cancelled = true; + if (aNewToBeCollapsed != tab.$TST.collapsed) { + tab.$TST.removeState(Constants.kTAB_STATE_COLLAPSING); + tab.$TST.removeState(Constants.kTAB_STATE_EXPANDING); + } + }; + const onCompleted = (tab, info = {}) => { + manager.removeListener(onCompleted); + if (cancelled || + !TabsStore.ensureLivingItem(tab)) // do nothing for closed tab! + return; + + if (shouldApplyAnimation() && + !info.justNow && + configs.collapseDuration > 0 && + changed) + return; // force completion is required only for non-animation case + + //log('=> skip animation'); + if (tab.$TST.collapsed) { + tab.$TST.removeState(Constants.kTAB_STATE_COLLAPSING); + tab.$TST.addState(Constants.kTAB_STATE_COLLAPSED_DONE); + } + else { + tab.$TST.removeState(Constants.kTAB_STATE_EXPANDING); + } + + TabsStore.updateVirtualScrollRenderabilityIndexForTab(tab); + onUpdated.dispatch(tab, { + collapsed: tab.$TST.collapsed, + anchor: info.anchor, + last: info.last + }); + }; + manager.addListener(onCompleted); + + if (!shouldApplyAnimation() || + info.justNow || + configs.collapseDuration < 1 || + !changed) { + //log('=> skip animation'); + onCompleted(tab, info); + return; + } + + tab.$TST.updatingCollapsedStateCanceller = canceller; + + if (tab.$TST.collapsed) { + tab.$TST.addState(Constants.kTAB_STATE_COLLAPSING); + } + else { + tab.$TST.addState(Constants.kTAB_STATE_EXPANDING); + tab.$TST.removeState(Constants.kTAB_STATE_COLLAPSED_DONE); + } + + TabsStore.updateVirtualScrollRenderabilityIndexForTab(tab); + onUpdated.dispatch(tab, { collapsed: info.collapsed }); + + const onCanceled = () => { + manager.removeListener(onCompleted); + }; + + window.requestAnimationFrame(() => { + if (cancelled || + !TabsStore.ensureLivingItem(tab)) { // it was removed while waiting + onCanceled(); + return; + } + + //log('start animation for ', dumpTab(tab)); + onUpdating.dispatch(tab, { + collapsed: tab.$TST.collapsed, + anchor: info.anchor, + last: info.last + }); + + tab.$TST.onEndCollapseExpandAnimation = (() => { + if (cancelled) { + onCanceled(); + return; + } + + //log('=> finish animation for ', dumpTab(tab)); + tab.$TST.removeState(Constants.kTAB_STATE_COLLAPSING); + tab.$TST.removeState(Constants.kTAB_STATE_EXPANDING); + + // The collapsed state of the tab can be changed by different trigger, + // so we must respect the actual status of the tab, instead of the + // "expected status" given via arguments. + if (tab.$TST.collapsed) + tab.$TST.addState(Constants.kTAB_STATE_COLLAPSED_DONE); + + TabsStore.updateVirtualScrollRenderabilityIndexForTab(tab); + onUpdated.dispatch(tab, { + collapsed: tab.$TST.collapsed + }); + }); + tab.$TST.onEndCollapseExpandAnimation.timeout = setTimeout(() => { + if (cancelled || + !TabsStore.ensureLivingItem(tab) || + !tab.$TST.onEndCollapseExpandAnimation) { + onCanceled(); + return; + } + delete tab.$TST.onEndCollapseExpandAnimation.timeout; + tab.$TST.onEndCollapseExpandAnimation(); + delete tab.$TST.onEndCollapseExpandAnimation; + }, configs.collapseDuration); + }); +} + +const BUFFER_KEY_PREFIX = 'collapse-expand-'; + +BackgroundConnection.onMessage.addListener(async message => { + switch (message.type) { + case Constants.kCOMMAND_NOTIFY_SUBTREE_COLLAPSED_STATE_CHANGED: { + if (BackgroundConnection.handleBufferedMessage(message, `${BUFFER_KEY_PREFIX}${message.tabId}`)) + return; + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + const lastMessage = BackgroundConnection.fetchBufferedMessage(message.type, `${BUFFER_KEY_PREFIX}${message.tabId}`); + if (!tab || + !lastMessage) + return; + tab.$TST.toggleState(Constants.kTAB_STATE_SUBTREE_COLLAPSED, lastMessage.collapsed); + tab.$TST.invalidateElement(TabInvalidationTarget.Twisty | TabInvalidationTarget.Tooltip); + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_COLLAPSED_STATE_CHANGED: { + if (BackgroundConnection.handleBufferedMessage(message, `${BUFFER_KEY_PREFIX}${message.tabId}`)) + return; + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + const lastMessage = BackgroundConnection.fetchBufferedMessage(message.type, `${BUFFER_KEY_PREFIX}${message.tabId}`); + if (!tab || + !lastMessage) + return; + if (tab.$TST.collapsedOnCreated) { // it may be already expanded by others! + if (!tab.$TST.collapsed) // expanded by someone, so clear the flag. + tab.$TST.collapsedOnCreated = false; + + // Unexpectedly kept as collapsed case may happen when only "collapsed" + // state was applied by broadcasting, so we clear it for now + if (tab.$TST.states.has(Constants.kTAB_STATE_EXPANDING) || + !tab.$TST.states.has(Constants.kTAB_STATE_COLLAPSED_DONE)) + return; + if (!tab.$TST.collapsed) { + tab.$TST.addState(Constants.kTAB_STATE_COLLAPSED_DONE); + tab.$TST.addState(Constants.kTAB_STATE_COLLAPSED); + TabsStore.removeVisibleTab(tab); + TabsStore.removeExpandedTab(tab); + } + } + setCollapsed(tab, { + collapsed: lastMessage.collapsed, + justNow: lastMessage.justNow, + anchor: Tab.get(lastMessage.anchorId), + last: lastMessage.last + }); + }; break; + } +}); diff --git a/waterfox/browser/components/sidebar/sidebar/components/TabCloseBoxElement.js b/waterfox/browser/components/sidebar/sidebar/components/TabCloseBoxElement.js new file mode 100644 index 000000000000..4d55bf8f26e6 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/components/TabCloseBoxElement.js @@ -0,0 +1,93 @@ +/* +# 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 NORMAL_TOOLTIP = 'tab_closebox_tab_tooltip'; +const MULTISELECTED_TOOLTIP = 'tab_closebox_tab_tooltip_multiselected'; +const TREE_TOOLTIP = 'tab_closebox_tree_tooltip'; + +export const kTAB_CLOSE_BOX_ELEMENT_NAME = 'tab-closebox'; + +const kTAB_CLOSE_BOX_CLASS_NAME = 'closebox'; + +export class TabCloseBoxElement extends HTMLElement { + static define() { + window.customElements.define(kTAB_CLOSE_BOX_ELEMENT_NAME, TabCloseBoxElement); + } + + constructor() { + super(); + + // We should initialize private properties with blank value for better performance with a fixed shape. + this._reservedUpdate = null; + + this.initialized = false; + } + + connectedCallback() { + if (this.initialized) { + this.invalidate(); + return; + } + + // I make ensure to call these operation only once conservatively because: + // * If we do these operations in a constructor of this class, Gecko throws `NotSupportedError: Operation is not supported`. + // * I'm not familiar with details of the spec, but this is not Gecko's bug. + // See https://dom.spec.whatwg.org/#concept-create-element + // "6. If result has children, then throw a "NotSupportedError" DOMException." + // * `connectedCallback()` may be called multiple times by append/remove operations. + // * `browser.i18n.getMessage()` might be a costly operation. + + // We preserve this class for backward compatibility with other addons. + this.classList.add(kTAB_CLOSE_BOX_CLASS_NAME); + + this.setAttribute('role', 'button'); + //this.setAttribute('tabindex', '0'); + + this.invalidate(); + this.setAttribute('draggable', true); // this is required to cancel click by dragging + + this.initialized = true; + } + + disconnectedCallback() { + if (this._reservedUpdate) { + this.removeEventListener('mouseover', this._reservedUpdate); + this._reservedUpdate = null; + } + } + + invalidate() { + if (this._reservedUpdate) + return; + + this._reservedUpdate = () => { + this._reservedUpdate = null; + this._updateTooltip(); + }; + this.addEventListener('mouseover', this._reservedUpdate, { once: true }); + } + + _updateTooltip() { + const tab = this.owner; + if (!tab || !tab.$TST) + return; + + let key; + if (tab.$TST.multiselected) + key = MULTISELECTED_TOOLTIP; + else if (tab.$TST.hasChild && tab.$TST.subtreeCollapsed) + key = TREE_TOOLTIP; + else + key = NORMAL_TOOLTIP; + + const tooltip = browser.i18n.getMessage(key); + this.setAttribute('title', tooltip); + } + + makeAccessible() { + this.setAttribute('aria-label', browser.i18n.getMessage('tab_closebox_aria_label', [this.owner.id])); + } +} diff --git a/waterfox/browser/components/sidebar/sidebar/components/TabCounterElement.js b/waterfox/browser/components/sidebar/sidebar/components/TabCounterElement.js new file mode 100644 index 000000000000..cd8670ca33d3 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/components/TabCounterElement.js @@ -0,0 +1,60 @@ +/* +# 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 { + configs +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; + +export const kTAB_COUNTER_ELEMENT_NAME = 'tab-counter'; + +const kTAB_COUNTER_CLASS_NAME = 'counter'; + +export class TabCounterElement extends HTMLElement { + static define() { + window.customElements.define(kTAB_COUNTER_ELEMENT_NAME, TabCounterElement); + } + + constructor() { + super(); + + this.initialized = false; + } + + connectedCallback() { + if (this.initialized) { + this.update(); + return; + } + + // I make ensure to call these operation only once conservatively because: + // * If we do these operations in a constructor of this class, Gecko throws `NotSupportedError: Operation is not supported`. + // * I'm not familiar with details of the spec, but this is not Gecko's bug. + // See https://dom.spec.whatwg.org/#concept-create-element + // "6. If result has children, then throw a "NotSupportedError" DOMException." + // * `connectedCallback()` may be called multiple times by append/remove operations. + // * `browser.i18n.getMessage()` might be a costly operation. + + // We preserve this class for backward compatibility with other addons. + this.classList.add(kTAB_COUNTER_CLASS_NAME); + + this.update(); + + this.initialized = true; + } + + update() { + const tab = this.owner; + if (!tab || !tab.$TST) + return; + + const descendants = tab.$TST.descendants; + let count = descendants.length; + if (configs.counterRole == Constants.kCOUNTER_ROLE_ALL_TABS) + count += 1; + this.textContent = count; + } +} diff --git a/waterfox/browser/components/sidebar/sidebar/components/TabFaviconElement.js b/waterfox/browser/components/sidebar/sidebar/components/TabFaviconElement.js new file mode 100644 index 000000000000..979abba59af0 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/components/TabFaviconElement.js @@ -0,0 +1,109 @@ +/* +# 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 * as Constants from '/common/constants.js'; + +export const kTAB_FAVICON_ELEMENT_NAME = 'tab-favicon'; + +const KFAVICON_CLASS_NAME = 'favicon'; + +const kATTR_NAME_SRC = 'src'; + +export class TabFaviconElement extends HTMLElement { + static define() { + window.customElements.define(kTAB_FAVICON_ELEMENT_NAME, TabFaviconElement); + } + + static get observedAttributes() { + return [kATTR_NAME_SRC]; + } + + constructor() { + super(); + } + + connectedCallback() { + if (this.initialized) { + this._applySrc(); + return; + } + + // I make ensure to call these operation only once conservatively because: + // * If we do these operations in a constructor of this class, Gecko throws `NotSupportedError: Operation is not supported`. + // * I'm not familiar with details of the spec, but this is not Gecko's bug. + // See https://dom.spec.whatwg.org/#concept-create-element + // "6. If result has children, then throw a "NotSupportedError" DOMException." + // * `connectedCallback()` may be called multiple times by append/remove operations. + // + // FIXME: + // Ideally, these descendants should be in shadow tree. Thus I don't change these element to custom elements. + // However, I hesitate to do it at this moment by these reasons. + // If we move these to shadow tree, + // * We need some rewrite our style. + // * This includes that we need to move almost CSS code into this file as a string. + // * I'm not sure about that whether we should require [CSS Shadow Parts](https://bugzilla.mozilla.org/show_bug.cgi?id=1559074). + // * I suspect we can resolve almost problems by using CSS Custom Properties. + + // We preserve this class for backward compatibility with other addons. + this.classList.add(KFAVICON_CLASS_NAME); + + this.insertAdjacentHTML('beforeend', ` + + + + + + `.trim().replace(/>\s+<')); + + this._applySrc(); + } + + get initialized() { + return !!this._imgElement; + } + + attributeChangedCallback(name, oldValue, newValue, _namespace) { + if (oldValue === newValue) { + return; + } + + switch (name) { + case kATTR_NAME_SRC: + this._applySrc(); + break; + + default: + throw new RangeError(`Handling \`${name}\` attribute has not been defined.`); + } + } + + _applySrc() { + const img = this._imgElement; + if (!img) + return; + const url = this.src; + if (url) + img.setAttribute('src', url); + else + img.removeAttribute('src'); + } + + get _imgElement() { + return this.firstElementChild; + } + + // These setter/getter is required by webextensions-lib-tab-favicon-helper + get src() { + return this.getAttribute(kATTR_NAME_SRC); + } + set src(value) { + if (value) + this.setAttribute(kATTR_NAME_SRC, value); + else + this.removeAttribute(kATTR_NAME_SRC); + } +} diff --git a/waterfox/browser/components/sidebar/sidebar/components/TabSoundButtonElement.js b/waterfox/browser/components/sidebar/sidebar/components/TabSoundButtonElement.js new file mode 100644 index 000000000000..adc82b71d32e --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/components/TabSoundButtonElement.js @@ -0,0 +1,89 @@ +/* +# 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/. +*/ + +export const kTAB_SOUND_BUTTON_ELEMENT_NAME = 'tab-sound-button'; + +const kTAB_SOUND_BUTTON_CLASS_NAME = 'sound-button'; + +export class TabSoundButtonElement extends HTMLElement { + static define() { + window.customElements.define(kTAB_SOUND_BUTTON_ELEMENT_NAME, TabSoundButtonElement); + } + + constructor() { + super(); + + // We should initialize private properties with blank value for better performance with a fixed shape. + this._reservedUpdate = null; + + this.initialized = false; + } + + connectedCallback() { + if (this.initialized) { + this.invalidate(); + return; + } + + // I make ensure to call these operation only once conservatively because: + // * If we do these operations in a constructor of this class, Gecko throws `NotSupportedError: Operation is not supported`. + // * I'm not familiar with details of the spec, but this is not Gecko's bug. + // See https://dom.spec.whatwg.org/#concept-create-element + // "6. If result has children, then throw a "NotSupportedError" DOMException." + // * `connectedCallback()` may be called multiple times by append/remove operations. + // * `browser.i18n.getMessage()` might be a costly operation. + + // We preserve this class for backward compatibility with other addons. + this.classList.add(kTAB_SOUND_BUTTON_CLASS_NAME); + + this.setAttribute('role', 'button'); + //this.setAttribute('tabindex', '0'); + + this.invalidate(); + + this.initialized = true; + } + + disconnectedCallback() { + if (this._reservedUpdate) { + this.removeEventListener('mouseover', this._reservedUpdate); + this._reservedUpdate = null; + } + } + + invalidate() { + if (this._reservedUpdate) + return; + + this._reservedUpdate = () => { + this._reservedUpdate = null; + this._updateTooltip(); + }; + this.addEventListener('mouseover', this._reservedUpdate, { once: true }); + } + + _updateTooltip() { + const tab = this.owner; + if (!tab || !tab.$TST) + return; + + const suffix = tab.$TST.multiselected ? '_multiselected' : '' ; + let key; + if (tab.$TST.maybeAutoplayBlocked) + key = `tab_soundButton_autoplayBlocked_tooltip${suffix}`; + else if (tab.$TST.maybeMuted) + key = `tab_soundButton_muted_tooltip${suffix}`; + else + key = `tab_soundButton_playing_tooltip${suffix}`; + + const tooltip = browser.i18n.getMessage(key); + this.setAttribute('title', tooltip); + } + + makeAccessible() { + this.setAttribute('aria-label', browser.i18n.getMessage('tab_soundButton_aria_label', [this.owner.id])); + } +} diff --git a/waterfox/browser/components/sidebar/sidebar/components/TabTwistyElement.js b/waterfox/browser/components/sidebar/sidebar/components/TabTwistyElement.js new file mode 100644 index 000000000000..04bfa4562335 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/components/TabTwistyElement.js @@ -0,0 +1,87 @@ +/* +# 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 EXPANDED_TOOLTIP = 'tab_twisty_expanded_tooltip'; +const COLLAPSED_TOOLTIP = 'tab_twisty_collapsed_tooltip'; + +export const kTAB_TWISTY_ELEMENT_NAME = 'tab-twisty'; + +const kTAB_TWISTY_CLASS_NAME = 'twisty'; + +export class TabTwistyElement extends HTMLElement { + static define() { + window.customElements.define(kTAB_TWISTY_ELEMENT_NAME, TabTwistyElement); + } + + constructor() { + super(); + + // We should initialize private properties with blank value for better performance with a fixed shape. + this._reservedUpdate = null; + + this.initialized = false; + } + + connectedCallback() { + if (this.initialized) { + this.invalidate(); + return; + } + + // I make ensure to call these operation only once conservatively because: + // * If we do these operations in a constructor of this class, Gecko throws `NotSupportedError: Operation is not supported`. + // * I'm not familiar with details of the spec, but this is not Gecko's bug. + // See https://dom.spec.whatwg.org/#concept-create-element + // "6. If result has children, then throw a "NotSupportedError" DOMException." + // * `connectedCallback()` may be called multiple times by append/remove operations. + // * `browser.i18n.getMessage()` might be a costly operation. + + // We preserve this class for backward compatibility with other addons. + this.classList.add(kTAB_TWISTY_CLASS_NAME); + + this.setAttribute('role', 'button'); + //this.setAttribute('tabindex', '0'); + + this.invalidate(); + + this.initialized = true; + } + + disconnectedCallback() { + if (this._reservedUpdate) { + this.removeEventListener('mouseover', this._reservedUpdate); + this._reservedUpdate = null; + } + } + + invalidate() { + if (this._reservedUpdate) + return; + + this._reservedUpdate = () => { + this._reservedUpdate = null; + this._updateTooltip(); + }; + this.addEventListener('mouseover', this._reservedUpdate, { once: true }); + } + + _updateTooltip() { + const tab = this.owner; + + let key; + if (tab?.$TST.subtreeCollapsed) + key = COLLAPSED_TOOLTIP; + else + key = EXPANDED_TOOLTIP; + + const tooltip = browser.i18n.getMessage(key); + this.setAttribute('title', tooltip); + } + + makeAccessible() { + this.setAttribute('aria-label', browser.i18n.getMessage('tab_twisty_aria_label', [this.owner.id])); + } +} diff --git a/waterfox/browser/components/sidebar/sidebar/components/TreeItemElement.js b/waterfox/browser/components/sidebar/sidebar/components/TreeItemElement.js new file mode 100644 index 000000000000..0a887fb10f9d --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/components/TreeItemElement.js @@ -0,0 +1,833 @@ +/* +# 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 { + configs, + sanitizeForHTMLText, +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; +import * as Permissions from '/common/permissions.js'; +import * as TabsStore from '/common/tabs-store.js'; +import { Tab, TreeItem } from '/common/TreeItem.js'; + +import TabFavIconHelper from '/extlib/TabFavIconHelper.js'; + +import { kTAB_TWISTY_ELEMENT_NAME } from './TabTwistyElement.js'; +import { kTAB_FAVICON_ELEMENT_NAME } from './TabFaviconElement.js'; +import { kTREE_ITEM_LABEL_ELEMENT_NAME } from './TreeItemLabelElement.js'; +import { kTAB_COUNTER_ELEMENT_NAME } from './TabCounterElement.js'; +import { kTAB_SOUND_BUTTON_ELEMENT_NAME } from './TabSoundButtonElement.js'; +import { kTAB_CLOSE_BOX_ELEMENT_NAME } from './TabCloseBoxElement.js'; + +export const kTREE_ITEM_ELEMENT_NAME = 'tab-item'; +export const kTREE_ITEM_SUBSTANCE_ELEMENT_NAME = 'tab-item-substance'; + +export const kEVENT_TREE_ITEM_SUBSTANCE_ENTER = 'tab-item-substance-enter'; +export const kEVENT_TREE_ITEM_SUBSTANCE_LEAVE = 'tab-item-substance-leave'; + +export const TabInvalidationTarget = Object.freeze({ + Twisty: 1 << 0, + SoundButton: 1 << 1, + CloseBox: 1 << 2, + Tooltip: 1 << 3, + Overflow: 1 << 4, + All: 1 << 0 | 1 << 1 | 1 << 2 | 1 << 3 | 1 << 4, +}); + +export const TabUpdateTarget = Object.freeze({ + Counter: 1 << 0, + Overflow: 1 << 1, + DescendantsHighlighted: 1 << 2, + CollapseExpandState: 1 << 3, + TabProperties: 1 << 4, + All: 1 << 0 | 1 << 1 | 1 << 2 | 1 << 3 | 1 << 4, +}); + +const kTAB_CLASS_NAME = 'tab'; + +const NATIVE_PROPERTIES = new Set([ + 'active', + 'attention', + 'audible', + 'discarded', + 'hidden', + 'highlighted', + 'pinned' +]); +const IGNORE_CLASSES = new Set([ + 'tab', + Constants.kTAB_STATE_ANIMATION_READY, + Constants.kTAB_STATE_SUBTREE_COLLAPSED +]); + +export class TreeItemElement extends HTMLElement { + static define() { + window.customElements.define(kTREE_ITEM_ELEMENT_NAME, TreeItemElement); + } + + constructor() { + super(); + + // We should initialize private properties with blank value for better performance with a fixed shape. + this._raw = null; + this._reservedUpdateTooltip = null; + this.__onMouseOver = null; + this.__onMouseEnter = null; + this.__onMouseLeave = null; + this.__onWindowResize = null; + this.__onConfigChange = null; + } + + connectedCallback() { + this.setAttribute('role', 'option'); + + if (this.initialized) { + this.initializeContents(); + this.invalidate(TabInvalidationTarget.All); + this.update(TabUpdateTarget.TabProperties); + this.applyAttributes(); + this._initExtraItemsContainers(); + this._startListening(); + return; + } + + // I make ensure to call these operation only once conservatively because: + // * If we do these operations in a constructor of this class, Gecko throws `NotSupportedError: Operation is not supported`. + // * I'm not familiar with details of the spec, but this is not Gecko's bug. + // See https://dom.spec.whatwg.org/#concept-create-element + // "6. If result has children, then throw a "NotSupportedError" DOMException." + // * `connectedCallback()` may be called multiple times by append/remove operations. + // + // FIXME: + // Ideally, these descendants should be in shadow tree. Thus I don't change these element to custom elements. + // However, I hesitate to do it at this moment by these reasons. + // If we move these to shadow tree, + // * We need some rewrite our style. + // * This includes that we need to move almost CSS code into this file as a string. + // * I'm not sure about that whether we should require [CSS Shadow Parts](https://bugzilla.mozilla.org/show_bug.cgi?id=1559074). + // * I suspect we can resolve almost problems by using CSS Custom Properties. + + // We preserve this class for backward compatibility with other addons. + this.classList.add(kTAB_CLASS_NAME); + + this.insertAdjacentHTML('beforeend', ` + + + <${kTREE_ITEM_SUBSTANCE_ELEMENT_NAME} draggable="true"> + + + + + <${kTAB_TWISTY_ELEMENT_NAME}> + + + + <${kTAB_FAVICON_ELEMENT_NAME}> + <${kTAB_SOUND_BUTTON_ELEMENT_NAME}> + <${kTREE_ITEM_LABEL_ELEMENT_NAME}> + <${kTAB_COUNTER_ELEMENT_NAME}> + <${kTAB_CLOSE_BOX_ELEMENT_NAME}> + + + + + + + + + `.trim().replace(/>\s+<')); + + this.removeAttribute('draggable'); + + this.initializeContents(); + this.invalidate(TabInvalidationTarget.All); + this.update(TabUpdateTarget.TabProperties); + this._initExtraItemsContainers(); + this.applyAttributes(); + this._startListening(); + } + + disconnectedCallback() { + if (this._reservedUpdateTooltip) { + this.removeEventListener('mouseover', this._reservedUpdateTooltip); + this._reservedUpdateTooltip = null; + } + this._endListening(); + this._raw = null; + } + + get initialized() { + return !!this.substanceElement; + } + + initializeContents() { + // This can be called after the tab is removed, so + // we need to initialize contents safely. + if (this._labelElement) { + if (!this._labelElement.owner) { + this._labelElement.addOverflowChangeListener(() => { + if (!this.$TST || + this.$TST.tab?.pinned) + return; + this.invalidateTooltip(); + }); + } + this._labelElement.owner = this; + } + if (this.twisty) { + this.twisty.owner = this; + this.twisty.makeAccessible(); + } + if (this._counterElement) + this._counterElement.owner = this; + if (this._soundButtonElement) { + this._soundButtonElement.owner = this; + this._soundButtonElement.makeAccessible(); + } + if (this.closeBox) { + this.closeBox.owner = this; + this.closeBox.makeAccessible(); + } + } + + get type() { + return this.getAttribute('type'); + } + + // Elements restored from cache are initialized without bundled tabs. + // Thus we provide abiltiy to get tab and service objects from cached/restored information. + get raw() { + return this._raw || ( + this._raw = (this.type == TreeItem.TYPE_GROUP ? + TabsStore.tabGroups.get(parseInt(this.getAttribute(Constants.kAPI_NATIVE_TAB_GROUP_ID))) : + this.type == TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER ? + TabsStore.tabGroups.get(parseInt(this.getAttribute(Constants.kAPI_NATIVE_TAB_GROUP_ID))).$TST.collapsedMembersCounterItem : + Tab.get(parseInt(this.getAttribute(Constants.kAPI_TAB_ID))) + ) + ); + } + set raw(value) { + return this._raw = value; + } + + get tab() { // for backward compatibility + return this.raw; + } + set tab(value) { + return this.raw = value; + } + + get $TST() { + return this._$TST || (this._$TST = this.raw && this.raw.$TST); + } + set $TST(value) { + return this._$TST = value; + } + + get substanceElement() { + return this.querySelector(kTREE_ITEM_SUBSTANCE_ELEMENT_NAME); + } + + get twisty() { + return this.querySelector(kTAB_TWISTY_ELEMENT_NAME); + } + + get favicon() { + return this.querySelector(kTAB_FAVICON_ELEMENT_NAME); + } + + get _labelElement() { + return this.querySelector(kTREE_ITEM_LABEL_ELEMENT_NAME); + } + + get _soundButtonElement() { + return this.querySelector(kTAB_SOUND_BUTTON_ELEMENT_NAME); + } + + get _counterElement() { + return this.querySelector(kTAB_COUNTER_ELEMENT_NAME); + } + + get closeBox() { + return this.querySelector(kTAB_CLOSE_BOX_ELEMENT_NAME); + } + + applyAttributes() { + this._labelElement.value = this.dataset.title; + this.favIconUrl = this._favIconUrl; + this.setAttribute('aria-selected', this.classList.contains(Constants.kTAB_STATE_HIGHLIGHTED) ? 'true' : 'false'); + + // for convenience on customization with custom user styles + this.substanceElement.setAttribute(Constants.kAPI_TAB_ID, this.getAttribute(Constants.kAPI_TAB_ID)); + this.substanceElement.setAttribute(Constants.kAPI_WINDOW_ID, this.getAttribute(Constants.kAPI_WINDOW_ID)); + this._labelElement.setAttribute(Constants.kAPI_TAB_ID, this.getAttribute(Constants.kAPI_TAB_ID)); + this._labelElement.setAttribute(Constants.kAPI_WINDOW_ID, this.getAttribute(Constants.kAPI_WINDOW_ID)); + + + switch (this.getAttribute('type')) { + case TreeItem.TYPE_TAB: + if (this.tab) { + this.dataset.index = + this.substanceElement.dataset.index = + this._labelElement.dataset.index = this.tab.index; + } + case TreeItem.TYPE_GROUP: + this.substanceElement.setAttribute('draggable', true); + break; + + default: + this.substanceElement.removeAttribute('draggable'); + break; + } + + this._labelElement.applyAttributes(); + } + + invalidate(targets) { + if (!this.initialized) + return; + + if (targets & TabInvalidationTarget.Twisty) + this.twisty?.invalidate(); + + if (targets & TabInvalidationTarget.SoundButton) + this._soundButtonElement?.invalidate(); + + if (targets & TabInvalidationTarget.CloseBox) + this.closeBox?.invalidate(); + + if (targets & TabInvalidationTarget.Tooltip) + this.invalidateTooltip(); + + if (targets & TabInvalidationTarget.Overflow) { + this._labelElement.invalidateOverflow(); + this._needToUpdateOverflow = true; + } + } + + invalidateTooltip() { + if (this._reservedUpdateTooltip) + return; + + this.useTabPreviewTooltip = false; + this.hasCustomTooltip = false; + Permissions.isGranted(Permissions.ALL_URLS); // cache last state for the _updateTooltip() + this._reservedUpdateTooltip = () => { + this._reservedUpdateTooltip = null; + this._updateTooltip(); + }; + this.addEventListener('mouseover', this._reservedUpdateTooltip, { once: true }); + } + + update(targets) { + if (!this.initialized) + return; + + if (targets & TabUpdateTarget.Counter) + this._counterElement?.update(); + + if (targets & TabUpdateTarget.Overflow) + this._updateOverflow(); + + if (targets & TabUpdateTarget.DescendantsHighlighted) + this._updateDescendantsHighlighted(); + + if (targets & TabUpdateTarget.CollapseExpandState) + this._updateCollapseExpandState(); + + if (targets & TabUpdateTarget.TabProperties) + this._updateTabProperties(); + } + + updateOverflow() { + if (this._needToUpdateOverflow || + configs.labelOverflowStyle == 'fade') + this._updateOverflow(); + this.invalidateTooltip(); + } + + _updateOverflow() { + this._needToUpdateOverflow = false; + this._labelElement?.updateOverflow(); + } + + _updateTooltip() { + if (!this.$TST) // called before binding on restoration from cache + return; + + const raw = this.$TST.raw; + const tabElement = raw?.$TST.element; + if (!tabElement) + return; + + // Priority of tooltip contents and methods + // 1. Is the tab preview panel activated by the user? (option) + // * NO => Use legacy tooltip anyway. + // - Set "title" attribute for the legacy tooltip, if the tab is faviconized, + // or the tab has long title with overflow state, or custom tooltip. + // - Otherwise remove "title" attribute to suppress the legacy tooltip. + // * YES => Go ahead. + // 2. Can we show tab preview panel in the active tab? (permission) + // * YES => Remove "title" attribute to suppress the legacy tooltip. + // Tooltip will be shown with tab preview panel in the active tab. + // * NO => Go ahead. + // 3. Do we have custom tooltip? (for collapsed tree, specified via API, etc.) + // * YES => Set "title" attribute for the legacy tooltip with custom contents. + // * NO => Go ahead for the default tooltip. + // 4. Can we show tab preview panel in the sidebar for the default tooltip? (option) + // * YES => Remove "title" attribute to suppress the legacy tooltip. + // The default tooltip will be shown with tab preview panel in the sidebar. + // * NO => Set "title" attribute for the legacy tooltip, if the tab is faviconized, + // or the tab has long title with overflow state. + + const canCaptureTab = Permissions.isGrantedSync(Permissions.ALL_URLS); + const canInjectScriptToTab = Permissions.canInjectScriptToTabSync(Tab.getActiveTab(TabsStore.getCurrentWindowId())); + this.useTabPreviewTooltip = !!( + configs.tabPreviewTooltip && + canCaptureTab && + (((configs.tabPreviewTooltipRenderIn & Constants.kIN_CONTENT_PANEL_RENDER_IN_CONTENT) && + canInjectScriptToTab) || + (configs.tabPreviewTooltipRenderIn & Constants.kIN_CONTENT_PANEL_RENDER_IN_SIDEBAR)) + ); + + let debugTooltip; + if (configs.debug) { + debugTooltip = ` +${raw.title} +#${raw.id} +(${tabElement.className}) +uniqueId = <${this.$TST.uniqueId.id}> +duplicated = <${!!this.$TST.uniqueId.duplicated}> / <${this.$TST.uniqueId.originalTabId}> / <${this.$TST.uniqueId.originalId}> +restored = <${!!this.$TST.uniqueId.restored}> +rawId = ${raw.id} +windowId = ${raw.windowId} +index = ${raw.index} +`.trim(); + this.$TST.setAttribute('title', debugTooltip); + if (!this.useTabPreviewTooltip) { + this.tooltip = debugTooltip; + this.tooltipHtml = `
              ${sanitizeForHTMLText(debugTooltip)}
              `; + return; + } + } + + this.tooltip = this.$TST.defaultTooltipText; + this.tooltipWithDescendants = this.$TST.tooltipTextWithDescendants; + this.tooltipHtml = this.$TST.tooltipHtml; + this.tooltipHtmlWithDescendants = this.$TST.tooltipHtmlWithDescendants; + + const appliedTooltipText = this.appliedTooltipText; + this.hasCustomTooltip = ( + appliedTooltipText !== null && + appliedTooltipText != this.$TST.defaultTooltipText + ); + //console.log('this.useTabPreviewTooltip ', { useTabPreviewTooltip: this.useTabPreviewTooltip, canRunScript, canInjectScriptToTab, hasCustomTooltip: this.hasCustomTooltip }); + + const tooltipText = configs.debug ? + debugTooltip : + (this.useTabPreviewTooltip && + (canInjectScriptToTab || + !(this.hasCustomTooltip && configs.showCollapsedDescendantsByLegacyTooltipOnSidebar))) ? + null : + appliedTooltipText; + if (typeof tooltipText == 'string') + this.$TST.setAttribute('title', tooltipText); + else + this.$TST.removeAttribute('title'); + } + + get appliedTooltipText() { + if (configs.showCollapsedDescendantsByTooltip && + this.$TST.subtreeCollapsed && + this.$TST.hasChild) { + return this.tooltipWithDescendants; + } + + const highPriorityTooltipText = this.$TST.highPriorityTooltipText; + if (typeof highPriorityTooltipText == 'string') { + if (highPriorityTooltipText) + return highPriorityTooltipText; + + return null; + } + + let tooltip = null; + + const raw = this.$TST.raw; + if (this.classList.contains('faviconized') || + this.overflow || + this.tooltip != raw.title) + tooltip = this.tooltip; + else + tooltip = null; + + const lowPriorityTooltipText = this.$TST.lowPriorityTooltipText; + if (typeof lowPriorityTooltipText == 'string' && + !this.getAttribute('title')) { + if (lowPriorityTooltipText) + tooltip = lowPriorityTooltipText; + else + tooltip = null; + } + return tooltip; + } + + get appliedTooltipHtml() { + if (configs.showCollapsedDescendantsByTooltip && + this.$TST.subtreeCollapsed && + this.$TST.hasChild) { + return this.tooltipHtmlWithDescendants; + } + + const highPriorityTooltipText = this.$TST.highPriorityTooltipText; + if (typeof highPriorityTooltipText == 'string') { + if (highPriorityTooltipText) + return sanitizeForHTMLText(highPriorityTooltipText); + + return null; + } + + let tooltip = null; + + const raw = this.$TST.raw; + if (this.classList.contains('faviconized') || + this.overflow || + this.tooltip != raw.title) + tooltip = this.tooltipHtml; + else + tooltip = null; + + const lowPriorityTooltipText = this.$TST.lowPriorityTooltipText; + if (typeof lowPriorityTooltipText == 'string' && + !this.getAttribute('title')) { + if (lowPriorityTooltipText) + tooltip = sanitizeForHTMLText(lowPriorityTooltipText); + else + tooltip = null; + } + return tooltip; + } + + _initExtraItemsContainers() { + if (!this.extraItemsContainerIndentRoot) { + this.extraItemsContainerIndentRoot = this.querySelector(`.${Constants.kEXTRA_ITEMS_CONTAINER}.indent`).attachShadow({ mode: 'open' }); + this.extraItemsContainerIndentRoot.itemById = new Map(); + } + if (!this.extraItemsContainerBehindRoot) { + this.extraItemsContainerBehindRoot = this.querySelector(`.${Constants.kEXTRA_ITEMS_CONTAINER}.behind`).attachShadow({ mode: 'open' }); + this.extraItemsContainerBehindRoot.itemById = new Map(); + } + if (!this.extraItemsContainerFrontRoot) { + this.extraItemsContainerFrontRoot = this.querySelector(`.${Constants.kEXTRA_ITEMS_CONTAINER}.front`).attachShadow({ mode: 'open' }); + this.extraItemsContainerFrontRoot.itemById = new Map(); + } + if (!this.extraItemsContainerAboveRoot) { + this.extraItemsContainerAboveRoot = this.querySelector(`.${Constants.kEXTRA_ITEMS_CONTAINER}.above`).attachShadow({ mode: 'open' }); + this.extraItemsContainerAboveRoot.itemById = new Map(); + } + if (!this.extraItemsContainerBelowRoot) { + this.extraItemsContainerBelowRoot = this.querySelector(`.${Constants.kEXTRA_ITEMS_CONTAINER}.below`).attachShadow({ mode: 'open' }); + this.extraItemsContainerBelowRoot.itemById = new Map(); + } + } + + _startListening() { + if (this.__onMouseOver) + return; + this.addEventListener('mouseover', this.__onMouseOver = this._onMouseOver.bind(this)); + this.addEventListener('mouseenter', this.__onMouseEnter = this._onMouseEnter.bind(this)); + this.substanceElement?.addEventListener('mouseenter', this.__onMouseEnter); + this.addEventListener('mouseleave', this.__onMouseLeave = this._onMouseLeave.bind(this)); + this.substanceElement?.addEventListener('mouseleave', this.__onMouseLeave); + window.addEventListener('resize', this.__onWindowResize = this._onWindowResize.bind(this)); + configs.$addObserver(this.__onConfigChange = this._onConfigChange.bind(this)); + } + + _endListening() { + if (!this.__onMouseOver) + return; + this.removeEventListener('mouseover', this.__onMouseOver); + this.__onMouseOver = null; + this.removeEventListener('mouseenter', this.__onMouseEnter); + this.substanceElement?.removeEventListener('mouseenter', this.__onMouseEnter); + this.__onMouseEnter = null; + this.removeEventListener('mouseleave', this.__onMouseLeave); + this.substanceElement?.removeEventListener('mouseleave', this.__onMouseLeave); + this.__onMouseLeave = null; + window.removeEventListener('resize', this.__onWindowResize); + this.__onWindowResize = null; + configs.$removeObserver(this.__onConfigChange); + this.__onConfigChange = null; + } + + _onMouseOver(_event) { + this._updateTabAndAncestorsTooltip(this.$TST.raw); + } + + _onMouseEnter(event) { + if (this.classList.contains('faviconized') != (event.target == this)) + return; + if (this._reservedUpdateTooltip) { + this.removeEventListener('mouseover', this._reservedUpdateTooltip); + this._updateTooltip(); + } + const tabSubstanceEnterEvent = new MouseEvent(kEVENT_TREE_ITEM_SUBSTANCE_ENTER, { + ...event, + clientX: event.clientX, + clientY: event.clientY, + screenX: event.screenX, + screenY: event.screenY, + bubbles: true, + composed: true, + }); + this.dispatchEvent(tabSubstanceEnterEvent); + } + + _onMouseLeave(event) { + if (this.classList.contains('faviconized') != (event.target == this)) + return; + const tabSubstanceLeaveEvent = new UIEvent(kEVENT_TREE_ITEM_SUBSTANCE_LEAVE, { + ...event, + bubbles: true, + composed: true, + }); + this.dispatchEvent(tabSubstanceLeaveEvent); + } + + _onWindowResize(_event) { + this.invalidateTooltip(); + } + + _onConfigChange(changedKey) { + switch (changedKey) { + case 'showCollapsedDescendantsByTooltip': + this.invalidateTooltip(); + break; + + case 'labelOverflowStyle': + this.updateOverflow(); + break; + } + } + + _updateTabAndAncestorsTooltip(tab) { + if (!TabsStore.ensureLivingItem(tab)) + return; + for (const updateTab of [tab].concat(tab.$TST.ancestors)) { + const tabElement = updateTab.$TST.element; + if (!tabElement) + continue; + tabElement.invalidateTooltip(); + // on the "fade" mode, overflow style was already updated, + // so we don' need to update the status here. + if (configs.labelOverflowStyle != 'fade') + tabElement.updateOverflow(); + } + } + + _updateDescendantsHighlighted() { + if (!this.$TST) // called before binding on restoration from cache + return; + + const children = this.$TST.children; + if (!this.$TST.hasChild) { + this.$TST.removeState(Constants.kTAB_STATE_SOME_DESCENDANTS_HIGHLIGHTED); + this.$TST.removeState(Constants.kTAB_STATE_ALL_DESCENDANTS_HIGHLIGHTED); + return; + } + let someHighlighted = false; + let allHighlighted = true; + for (const child of children) { + if (child.$TST.states.has(Constants.kTAB_STATE_HIGHLIGHTED)) { + someHighlighted = true; + allHighlighted = ( + allHighlighted && + (!child.$TST.hasChild || + child.$TST.states.has(Constants.kTAB_STATE_ALL_DESCENDANTS_HIGHLIGHTED)) + ); + } + else { + if (!someHighlighted && + child.$TST.states.has(Constants.kTAB_STATE_SOME_DESCENDANTS_HIGHLIGHTED)) { + someHighlighted = true; + } + allHighlighted = false; + } + } + if (someHighlighted) { + this.$TST.addState(Constants.kTAB_STATE_SOME_DESCENDANTS_HIGHLIGHTED); + this.$TST.toggleState(Constants.kTAB_STATE_ALL_DESCENDANTS_HIGHLIGHTED, allHighlighted); + } + else { + this.$TST.removeState(Constants.kTAB_STATE_SOME_DESCENDANTS_HIGHLIGHTED); + this.$TST.removeState(Constants.kTAB_STATE_ALL_DESCENDANTS_HIGHLIGHTED); + } + } + + _updateCollapseExpandState() { + if (!this.$TST) // called before binding on restoration from cache + return; + + const classList = this.classList; + const parent = this.$TST.parent; + if (this.$TST.collapsed || + (parent && + (parent.$TST.collapsed || + parent.$TST.subtreeCollapsed))) { + if (!classList.contains(Constants.kTAB_STATE_COLLAPSED)) + classList.add(Constants.kTAB_STATE_COLLAPSED); + if (!classList.contains(Constants.kTAB_STATE_COLLAPSED_DONE)) + classList.add(Constants.kTAB_STATE_COLLAPSED_DONE); + } + else { + if (classList.contains(Constants.kTAB_STATE_COLLAPSED)) + classList.remove(Constants.kTAB_STATE_COLLAPSED); + if (classList.contains(Constants.kTAB_STATE_COLLAPSED_DONE)) + classList.remove(Constants.kTAB_STATE_COLLAPSED_DONE); + } + } + + _updateTabProperties() { + if (!this.$TST) // called before binding on restoration from cache + return; + + const raw = this.$TST.raw; + const classList = this.classList; + + this.label = raw.$TST.title; + + const tab = this.$TST.tab; + if (tab) { + const openerOfGroupTab = tab && this.$TST.isGroupTab && Tab.getOpenerFromGroupTab(tab); + this.favIconUrl = openerOfGroupTab?.favIconUrl || tab?.favIconUrl; + + for (const state of classList) { + if (IGNORE_CLASSES.has(state) || + NATIVE_PROPERTIES.has(state)) + continue; + if (!this.$TST.states.has(state)) + classList.remove(state); + } + for (const state of this.$TST.states) { + if (IGNORE_CLASSES.has(state)) + continue; + if (!classList.contains(state)) + classList.add(state); + } + + for (const state of NATIVE_PROPERTIES) { + if (raw[state] == classList.contains(state)) + continue; + classList.toggle(state, raw[state]); + } + + if (this.$TST.childIds.length > 0) + this.setAttribute(Constants.kCHILDREN, `|${this.$TST.childIds.join('|')}|`); + else + this.removeAttribute(Constants.kCHILDREN); + + if (this.$TST.parentId) + this.setAttribute(Constants.kPARENT, this.$TST.parentId); + else + this.removeAttribute(Constants.kPARENT); + + const alreadyGrouped = this.$TST.getAttribute(Constants.kPERSISTENT_ALREADY_GROUPED_FOR_PINNED_OPENER) || ''; + if (this.getAttribute(Constants.kPERSISTENT_ALREADY_GROUPED_FOR_PINNED_OPENER) != alreadyGrouped) + this.setAttribute(Constants.kPERSISTENT_ALREADY_GROUPED_FOR_PINNED_OPENER, alreadyGrouped); + + const opener = this.$TST.getAttribute(Constants.kPERSISTENT_ORIGINAL_OPENER_TAB_ID) || ''; + if (this.getAttribute(Constants.kPERSISTENT_ORIGINAL_OPENER_TAB_ID) != opener) + this.setAttribute(Constants.kPERSISTENT_ORIGINAL_OPENER_TAB_ID, opener); + + const uri = this.$TST.getAttribute(Constants.kCURRENT_URI) || tab?.url; + if (this.getAttribute(Constants.kCURRENT_URI) != uri) + this.setAttribute(Constants.kCURRENT_URI, uri); + + const favIconUri = this.$TST.getAttribute(Constants.kCURRENT_FAVICON_URI) || tab?.favIconUrl; + if (this.getAttribute(Constants.kCURRENT_FAVICON_URI) != favIconUri) + this.setAttribute(Constants.kCURRENT_FAVICON_URI, favIconUri); + + const level = this.$TST.getAttribute(Constants.kLEVEL) || 0; + if (this.getAttribute(Constants.kLEVEL) != level) + this.setAttribute(Constants.kLEVEL, level); + + const id = this.$TST.uniqueId.id; + if (this.getAttribute(Constants.kPERSISTENT_ID) != id) + this.setAttribute(Constants.kPERSISTENT_ID, id); + + if (this.$TST.subtreeCollapsed) { + if (!classList.contains(Constants.kTAB_STATE_SUBTREE_COLLAPSED)) + classList.add(Constants.kTAB_STATE_SUBTREE_COLLAPSED); + } + else { + if (classList.contains(Constants.kTAB_STATE_SUBTREE_COLLAPSED)) + classList.remove(Constants.kTAB_STATE_SUBTREE_COLLAPSED); + } + } + + const group = this.$TST.nativeTabGroup || this.$TST.group; + if (group) { + this.style.setProperty('--tab-group-color', `var(--tab-group-color-${group.color})`); + this.style.setProperty('--tab-group-color-pale', `var(--tab-group-color-${group.color}-pale)`); + this.style.setProperty('--tab-group-color-invert', `var(--tab-group-color-${group.color}-invert)`); + } + if (this.$TST.group) { + classList.toggle(Constants.kTAB_STATE_SUBTREE_COLLAPSED, group.collapsed); + } + } + + get favIconUrl() { + if (!this.initialized) + return null; + + return this.favicon.src; + } + + set favIconUrl(url) { + this._favIconUrl = url; + if (!this.initialized || !this.$TST) + return url; + + if (!url || url.startsWith('data:')) { // we don't need to use the helper for data: URI. + this.favicon.src = url; + this.favicon.classList.remove('error'); + return url; + } + + TabFavIconHelper.loadToImage({ + image: this.favicon, + tab: this.$TST.tab, + url + }); + return url; + } + + get overflow() { + const label = this._labelElement; + return label?.overflow; + } + + get label() { + const label = this._labelElement; + return label ? label.value : null; + } + set label(value) { + const label = this._labelElement; + if (label) + label.value = value; + + this.dataset.title = value; // for custom CSS https://github.com/piroor/treestyletab/issues/2242 + + if (!this.$TST) // called before binding on restoration from cache + return; + + this.invalidateTooltip(); + if (this.$TST.collapsed) { + this._labelElement.invalidateOverflow(); + this._needToUpdateOverflow = true; + } + } +} diff --git a/waterfox/browser/components/sidebar/sidebar/components/TreeItemLabelElement.js b/waterfox/browser/components/sidebar/sidebar/components/TreeItemLabelElement.js new file mode 100644 index 000000000000..0da7d8217dfd --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/components/TreeItemLabelElement.js @@ -0,0 +1,234 @@ +/* +# 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 { + watchOverflowStateChange, +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; + +export const kTREE_ITEM_LABEL_ELEMENT_NAME = 'tab-label'; + +const KLABEL_CLASS_NAME = 'label'; +const kCONTENT_CLASS_NAME = `${KLABEL_CLASS_NAME}-content`; + +const kATTR_NAME_VALUE = 'value'; + +//**************************************************************************** +// isRTL https://github.com/kavirajk/isRTL +// The MIT License (MIT) +// Copyright (c) 2013 dhilipsiva +const rtlChars = [ + /* arabic ranges*/ + '\u0600-\u06FF', + '\u0750-\u077F', + '\uFB50-\uFDFF', + '\uFE70-\uFEFF', + /* hebrew range*/ + '\u05D0-\u05FF' +].join(''); + +const reRTL = new RegExp(`[${rtlChars}]`, 'g'); + +function isRTL(text) { + if (!text) + return false; + if (/^\s*\u200f[^\u200e]/.test(text)) // title starting with right-to-left-mark + return true; + const textCount = text.replace(/[0-9\s\\\/.,\-+="']/g, '').length; // remove multilengual characters from count + const rtlCount = (text.match(reRTL) || []).length; + return rtlCount >= (textCount-rtlCount) && textCount > 0; +}; +//**************************************************************************** + +export class TreeItemLabelElement extends HTMLElement { + static define() { + window.customElements.define(kTREE_ITEM_LABEL_ELEMENT_NAME, TreeItemLabelElement); + } + + static get observedAttributes() { + return [kATTR_NAME_VALUE]; + } + + constructor() { + super(); + + // We should initialize private properties with blank value for better performance with a fixed shape. + this.__unwatch = null; + } + + connectedCallback() { + this.setAttribute('role', 'button'); + + if (this.initialized) { + this._startListening(); + this.applyAttributes(); + this.updateTextContent(); + return; + } + + // I make ensure to call these operation only once conservatively because: + // * If we do these operations in a constructor of this class, Gecko throws `NotSupportedError: Operation is not supported`. + // * I'm not familiar with details of the spec, but this is not Gecko's bug. + // See https://dom.spec.whatwg.org/#concept-create-element + // "6. If result has children, then throw a "NotSupportedError" DOMException." + // * `connectedCallback()` may be called multiple times by append/remove operations. + // + // FIXME: + // Ideally, these descendants should be in shadow tree. Thus I don't change these element to custom elements. + // However, I hesitate to do it at this moment by these reasons. + // If we move these to shadow tree, + // * We need some rewrite our style. + // * This includes that we need to move almost CSS code into this file as a string. + // * I'm not sure about that whether we should require [CSS Shadow Parts](https://bugzilla.mozilla.org/show_bug.cgi?id=1559074). + // * I suspect we can resolve almost problems by using CSS Custom Properties. + + // We preserve this class for backward compatibility with other addons. + this.classList.add(KLABEL_CLASS_NAME); + + const content = this.appendChild(document.createElement('span')); + content.classList.add(kCONTENT_CLASS_NAME); + + this._startListening(); + this.applyAttributes(); + this.updateTextContent(); + } + + disconnectedCallback() { + this._overflowChangeListeners.clear(); + this._endListening(); + } + + get initialized() { + return !!this._content; + } + + attributeChangedCallback(name, oldValue, newValue, _namespace) { + if (oldValue === newValue) { + return; + } + + switch (name) { + case kATTR_NAME_VALUE: + this.updateTextContent(); + break; + + default: + throw new RangeError(`Handling \`${name}\` attribute has not been defined.`); + } + } + + applyAttributes() { + // for convenience on customization with custom user styles + this._content.setAttribute(Constants.kAPI_TAB_ID, this.getAttribute(Constants.kAPI_TAB_ID)); + this._content.setAttribute(Constants.kAPI_WINDOW_ID, this.getAttribute(Constants.kAPI_WINDOW_ID)); + this._content.dataset.index = this.dataset.index; + } + + updateTextContent() { + const content = this._content; + if (!content) + return; + content.textContent = this.getAttribute(kATTR_NAME_VALUE) || ''; + this.classList.toggle('rtl', isRTL(content.textContent)); + this.invalidateOverflow(); + // Don't touch to offsetWidth if not needed - touching it will triggers indent animation unexpectedly + this.closest('tab-item[type="group"]')?.style.setProperty('--tab-label-width', `${content.offsetWidth}px`); + } + + updateOverflow() { + // Accessing to the real size of the element triggers layouting and hits the performance, + // like https://github.com/piroor/treestyletab/issues/3477 . + // So we need to throttle the process for better formance. + if (this.updateOverflow.invoked) + return; + this.updateOverflow.invoked = true; + window.requestAnimationFrame(() => { + this.updateOverflow.invoked = false; + if (!this.closest('body')) // already detached from document! + return; + const tab = this.owner; + const overflow = tab && !tab.pinned && this._content.offsetWidth > this.offsetWidth; + this.classList.toggle('overflow', overflow); + // Don't touch to offsetWidth if not needed - touching it will triggers indent animation unexpectedly + this.closest('tab-item[type="group"]')?.style.setProperty('--tab-label-width', `${this._content.offsetWidth}px`); + }); + } + + invalidateOverflow() { + this.updateOverflow.invoked = false; + } + + get _content() { + return this.querySelector(`.${kCONTENT_CLASS_NAME}`); + } + + get value() { + return this.getAttribute(kATTR_NAME_VALUE); + } + set value(value) { + this.setAttribute(kATTR_NAME_VALUE, value); + } + + get overflow() { + return this.classList.contains('overflow'); + } + + _startListening() { + if (this.__unwatch) + return; + + // Accessing to the real size of the element triggers layouting and hits the performance, + // like https://github.com/piroor/treestyletab/issues/3557 . + // So we need to throttle the process for better formance. + if (this._startListening_invoked) + return; + this._startListening_invoked = true; + window.requestAnimationFrame(() => { + this._startListening_invoked = false; + if (!this.closest('body')) // already detached from document! + return; + this.__unwatch = watchOverflowStateChange({ + target: this, + horizontal: true, + onOverflow: () => this._onOverflow(), + onUnderflow: () => this._onUnderflow(), + }); + }); + } + + _endListening() { + if (!this.__unwatch) + return; + this.__unwatch(); + this.__unwatch = null; + } + + _onOverflow() { + this.classList.add('overflow'); + for (const listener of this._overflowChangeListeners) { + listener(); + } + } + + _onUnderflow() { + this.classList.remove('overflow'); + for (const listener of this._overflowChangeListeners) { + listener(); + } + } + + addOverflowChangeListener(listener) { + this._overflowChangeListeners.add(listener); + } + + removeOverflowChangeListener(listener) { + this._overflowChangeListeners.delete(listener); + } + + get _overflowChangeListeners() { + return this.__overflowChangeListeners || (this.__overflowChangeListeners = new Set()); + } +} diff --git a/waterfox/browser/components/sidebar/sidebar/drag-and-drop.js b/waterfox/browser/components/sidebar/sidebar/drag-and-drop.js new file mode 100644 index 000000000000..0ee2c6083445 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/drag-and-drop.js @@ -0,0 +1,1814 @@ +/* ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is the Tree Style Tab. + * + * The Initial Developer of the Original Code is YUKI "Piro" Hiroshi. + * Portions created by the Initial Developer are Copyright (C) 2010-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): YUKI "Piro" Hiroshi + * Infocatcher + * Tetsuharu OHZEKI + * + * ***** END LICENSE BLOCK ******/ +'use strict'; + + +import RichConfirm from '/extlib/RichConfirm.js'; + +import { + log as internalLogger, + wait, + mapAndFilter, + configs, + shouldApplyAnimation, + sha1sum, + isMacOS, + isLinux, + isRTL, + dumpTab, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as BackgroundConnection from './background-connection.js'; +import * as Constants from '/common/constants.js'; +import * as EventUtils from './event-utils.js'; +import * as RetrieveURL from '/common/retrieve-url.js'; +import * as Scroll from './scroll.js'; +import * as SidebarItems from './sidebar-items.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TreeBehavior from '/common/tree-behavior.js'; +import * as TSTAPI from '/common/tst-api.js'; + +import { Tab, TabGroup, TreeItem } from '/common/TreeItem.js'; + +import * as Notifications from './notifications.js'; +import * as Size from './size.js'; + +function log(...args) { + internalLogger('sidebar/drag-and-drop', ...args); +} + + +const kTREE_DROP_TYPE = 'application/x-ws-tree'; +const kTYPE_ADDON_DRAG_DATA = `application/x-ws-drag-data;provider=${browser.runtime.id}&id=`; + +const kDROP_BEFORE = 'before'; +const kDROP_ON_SELF = 'self'; +const kDROP_AFTER = 'after'; +const kDROP_HEAD = 'head'; +const kDROP_TAIL = 'tail'; +const kDROP_IMPOSSIBLE = 'impossible'; + +const kDROP_POSITION = 'data-drop-position'; +const kINLINE_DROP_POSITION = 'data-inline-drop-position'; +const kNEXT_GROUP_COLOR = 'data-next-group-color'; + +let mLongHoverExpandedTabs = []; +let mLongHoverTimer; +let mLongHoverTimerNext; + +let mDraggingOnSelfWindow = false; +let mDraggingOnDraggedItems = false; + +let mCapturingForDragging = false; +let mReadyToCaptureMouseEvents = false; +let mLastDragEnteredTarget = null; +let mLastDropPosition = null; +let mLastInlineDropPosition = null; +let mLastDragEventCoordinates = null; +let mDragTargetIsClosebox = false; +let mCurrentDragData = null; + +let mInstanceId; + +export function init() { + document.addEventListener('dragstart', onDragStart); + document.addEventListener('dragover', onDragOver); + document.addEventListener('dragenter', onDragEnter); + document.addEventListener('dragleave', onDragLeave); + document.addEventListener('dragend', onDragEnd); + document.addEventListener('drop', onDrop); + + browser.runtime.onMessage.addListener(onMessage); + + browser.runtime.sendMessage({ type: Constants.kCOMMAND_GET_INSTANCE_ID }).then(id => mInstanceId = id); +} + + +export function isCapturingForDragging() { + return mCapturingForDragging; +} + +export function endMultiDrag(tab, coordinates) { + if (mCapturingForDragging) { + window.removeEventListener('mouseover', onTSTAPIDragEnter, { capture: true }); + window.removeEventListener('mouseout', onTSTAPIDragExit, { capture: true }); + document.releaseCapture(); + + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_TAB_DRAGEND, + tab, + window: tab?.windowId, + windowId: tab?.windowId, + clientX: coordinates.clientX, + clientY: coordinates.clientY + }, { tabProperties: ['tab'] }).catch(_error => {}); + + mLastDragEnteredTarget = null; + } + else if (mReadyToCaptureMouseEvents) { + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_TAB_DRAGCANCEL, + tab, + window: tab?.windowId, + windowId: tab?.windowId, + clientX: coordinates.clientX, + clientY: coordinates.clientY + }, { tabProperties: ['tab'] }).catch(_error => {}); + } + mCapturingForDragging = false; + mReadyToCaptureMouseEvents = false; +} + +function setDragData(dragData) { + return mCurrentDragData = dragData; +} + + +/* helpers */ + +function getDragDataFromOneItem(item, options = {}) { + const sessionId = `${Date.now()}-${Math.floor(Math.random() * 65000)}`; + if (!item) + return { + tab: null, + tabs: [], + item: null, + items: [], + structure: [], + nextGroupColor: TabGroup.getNextUnusedColor(), + windowId: null, + instanceId: mInstanceId, + sessionId, + }; + const items = getDraggedItemsFromOneItem(item, options); + const tab = item.$TST.tab; + const tabs = items.filter(item => item.type == TreeItem.TYPE_TAB); + return { + item, + items, + tab, + tabs, + structure: TreeBehavior.getTreeStructureFromTabs(tabs), + nextGroupColor: TabGroup.getNextUnusedColor(), + windowId: item.windowId, + instanceId: mInstanceId, + sessionId, + }; +} + +function getDraggedItemsFromOneItem(item, { asTree } = {}) { + if (item.$TST.group) { + return [item]; + } + if (item.$TST.multiselected) { + return Tab.getSelectedTabs(item.windowId); + } + if (asTree) { + return [item].concat(item.$TST.descendants); + } + return [item]; +} + +function sanitizeDragData(dragData) { + return { + item: dragData.item?.$TST.sanitized, + items: dragData.items.map(item => item?.$TST.sanitized), + tab: dragData.tab?.$TST.sanitized, + tabs: dragData.tabs.map(tab => tab?.$TST.sanitized), + structure: dragData.structure, + nextGroupColor: dragData.nextGroupColor, + windowId: dragData.windowId, + instanceId: dragData.instanceId, + sessionId: dragData.sessionId, + behavior: dragData.behavior, + individualOnOutside: dragData.individualOnOutside, + }; +} + +function getDropAction(event) { + const dragOverItem = EventUtils.getTreeItemFromEvent(event); + const targetItem = dragOverItem || EventUtils.getTreeItemFromTabbarEvent(event); + const info = { + dragOverItem, + targetItem, + substanceTargetItem: targetItem?.pinned && targetItem.$TST.bundledTab, + dropPosition: null, + inlineDropPosition: '', + action: null, + parent: null, + insertBefore: null, + insertAfter: null, + defineGetter(name, getter) { + delete this[name]; + Object.defineProperty(this, name, { + get() { + delete this[name]; + return this[name] = getter.call(this); + }, + configurable: true, + enumerable: true + }); + } + }; + info.defineGetter('dragData', () => { + const dragData = event.dataTransfer.getData(kTREE_DROP_TYPE); + return (dragData && JSON.parse(dragData)) || mCurrentDragData; + }); + info.defineGetter('draggedItem', () => { + const dragData = info.dragData; + if (dragData?.instanceId != mInstanceId) + return null; + const item = dragData?.item; + return TreeItem.get(item) || item; + }); + info.defineGetter('draggedItems', () => { + const dragData = info.dragData; + if (dragData?.instanceId != mInstanceId) + return []; + const itemIds = dragData?.items; + return !itemIds ? [] : mapAndFilter(itemIds, id => + Tab.get(id) || + TabGroup.get(id) || + undefined + ); + }); + info.defineGetter('draggedItemIds', () => { + return info.draggedItems.map(item => item.id); + }); + info.defineGetter('firstTargetableItem', () => { + const items = Scroll.getRenderableTreeItems(); + return items.length > 0 ? items[0] : Tab.getFirstTab(TabsStore.getCurrentWindowId()); + }); + info.defineGetter('lastTargetableItem', () => { + const items = Scroll.getRenderableTreeItems(); + return items.length > 0 ? items[items.length - 1] : Tab.getLastTab(TabsStore.getCurrentWindowId()); + }); + info.defineGetter('sanitizedDropOnTargetItem', () => { // the drop target we are trying to drop on itself + return info.dropPosition == kDROP_ON_SELF ? + (targetItem?.$TST.sanitized || targetItem) : + null; + }); + info.defineGetter('sanitizedDropBeforeTargetItem', () => { // the drop target we are trying to drop before it + return info.dropPosition == kDROP_BEFORE ? + (targetItem?.$TST.sanitized || targetItem) : + null; + }); + info.defineGetter('sanitizedDropAfterTargetItem', () => { // the drop target we are trying to drop after it + return info.dropPosition == kDROP_AFTER ? + (targetItem?.$TST.sanitized || targetItem) : + null; + }); + info.defineGetter('groupId', () => { // the group ID the dropped items should be grouped under + if (!targetItem) { + return null; + } + const draggedGroup = info.draggedItem?.type == TreeItem.TYPE_GROUP ? info.draggedItem : null; + switch (info.dropPosition) { + case kDROP_ON_SELF: + default: + return targetItem.groupId || targetItem.id; + + case kDROP_AFTER: + if (targetItem.type == TreeItem.TYPE_GROUP) { + return targetItem.collapsed ? + draggedGroup?.id : // dropping after a collapsed group => keep the original group + targetItem.id; // otherwise we try to insert items at the top of a group + } + return targetItem.groupId == -1 ? + draggedGroup?.id : // dropping after ungrouped tab => keep the original group + targetItem.groupId; // otherwise we try to add items to the group of the tab + + case kDROP_BEFORE: + if (targetItem.type == TreeItem.TYPE_GROUP) { + const previousTab = targetItem.$TST?.firstMember?.$TST?.unsafePreviousTab; + if (!draggedGroup && + (previousTab?.$TST?.nativeTabGroup?.collapsed || + (previousTab && + previousTab.groupId != -1 && + info.draggedItems.some(tab => tab == previousTab)))) { + // Keep dropped tabs ungrouped (or remove from groups) when tabs are explicitly dropped between groups + return -1; + } + return previousTab?.groupId || -1; + } + return targetItem.groupId == -1 ? + draggedGroup?.id : // dropping before ungrouped tab => keep the original group + targetItem.groupId; // otherwise we are trying to add items to the group of the tab + } + }); + info.defineGetter('canDrop', () => { + if (info.dropPosition == kDROP_IMPOSSIBLE) { + log('canDrop:undroppable: dropPosition == kDROP_IMPOSSIBLE'); + return false; + } + + const draggedItem = info.dragData?.item; + const isPrivateBrowsingTabDragged = draggedItem?.incognito; + const isPrivateBrowsingDropTarget = (info.dragOverItem || Tab.getFirstTab(TabsStore.getCurrentWindowId())).incognito; + if (draggedItem && + isPrivateBrowsingTabDragged != isPrivateBrowsingDropTarget) { + log('canDrop:undroppable: mismatched incognito status'); + return false; + } + else if (info.draggedItem) { + if (info.action & Constants.kACTION_ATTACH) { + if (info.draggedItem.windowId != TabsStore.getCurrentWindowId()) { + return true; + } + if (!configs.moveSoloTabOnDropParentToDescendant && + info.parent?.id == info.draggedItem.id) { + log('canDrop:undroppable: drop on child'); + return false; + } + if (info.dragOverItem) { + if (info.draggedItem.id == info.dragOverItem.id) { + log('canDrop:undroppable: on self'); + return false; + } + if (info.draggedItem.highlighted && + info.dragOverItem.highlighted && + info.draggedItemIds.includes(info.dragOverItem.id)) { + log('canDrop:undroppable: on dragging multiselected tabs'); + return false; + } + if (configs.moveSoloTabOnDropParentToDescendant) + return true; + const ancestors = info.dragOverItem.$TST.ancestors; + /* too many function call in this way, so I use alternative way for better performance. + return !info.draggedItemIds.includes(info.dragOverItem.id) && + Tab.collectRootTabs(info.draggedItems).every(rootTab => + !ancestors.includes(rootTab) + ); + */ + for (const item of info.draggedItems.slice(0).reverse()) { + const parent = item.$TST.parent; + if (!parent && ancestors.includes(parent)) { + log('canDrop:undroppable: on descendant'); + return false; + } + } + return true; + } + } + } + + if (info.dragOverItem && + (info.dragOverItem.hidden || + (info.dragOverItem.$TST.collapsed && + info.dropPosition != kDROP_AFTER))) { + log('canDrop:undroppable: on hidden tab'); + return false; + } + + return true; + }); + info.defineGetter('canCreateGroup', () => { + if (!configs.tabGroupsEnabled || + !targetItem || + targetItem.groupId != -1 || + [targetItem, ...info.draggedItems].some(item => item?.type != TreeItem.TYPE_TAB || item?.pinned || item.groupId != -1)) { + return false; + } + return info.dropPosition == kDROP_ON_SELF && info.inlineDropPosition == kDROP_HEAD; + }); + info.defineGetter('EventUtils.isCopyAction', () => EventUtils.isCopyAction(event)); + info.defineGetter('dropEffect', () => getDropEffectFromDropAction(info)); + + if (!targetItem) { + //log('dragging on non-tab element'); + const action = Constants.kACTION_MOVE | Constants.kACTION_DETACH; + if (event.clientY < Scroll.getItemRect(info.firstTargetableItem).top) { + //log('dragging above the first tab'); + info.targetItem = info.insertBefore = info.firstTargetableItem; + info.dropPosition = kDROP_BEFORE; + info.action = action; + if (info.draggedItem && + !info.draggedItem.pinned && + info.targetItem.pinned) { + log('undroppable: above the first tab'); + info.dropPosition = kDROP_IMPOSSIBLE; + } + } + else if (event.clientY > Scroll.getItemRect(info.lastTargetableItem).bottom) { + //log('dragging below the last tab'); + info.targetItem = info.insertAfter = info.lastTargetableItem; + info.dropPosition = kDROP_AFTER; + info.action = action; + if (info.draggedItem?.pinned && + !info.targetItem.pinned) { + log('undroppable: below the last tab'); + info.dropPosition = kDROP_IMPOSSIBLE; + } + } + return info; + } + + /** + * Basically, tabs should have three areas for dropping of items: + * [start][center][end], but, pinned tabs couldn't have its tree. + * So, if a tab is dragged and the target tab is pinned, then, we + * have to ignore the [center] area. + */ + const onFaviconizedTab = targetItem.pinned && configs.faviconizePinnedTabs; + const dropAreasCount = ( + info.draggedItem && + ((targetItem.pinned && !info.substanceTargetItem) || + (info.draggedItem.type == TreeItem.TYPE_GROUP && + targetItem.type != TreeItem.TYPE_GROUP)) + ) ? 2 : 3 ; + const targetItemRect = Scroll.getItemRect(targetItem); + const targetItemCoordinate = onFaviconizedTab ? targetItemRect.left : targetItemRect.top ; + const targetItemSize = onFaviconizedTab ? targetItemRect.width : targetItemRect.height ; + let beforeOrAfterDropAreaSize; + if (dropAreasCount == 2) { + beforeOrAfterDropAreaSize = Math.round(targetItemSize / dropAreasCount); + } + else { // enlarge the area to dop something on the tab itself + beforeOrAfterDropAreaSize = Math.round(targetItemSize / 4); + } + const eventCoordinate = onFaviconizedTab ? event.clientX : event.clientY; + /* + log('coordinates: ', { + event: eventCoordinate, + targetItem: targetItemCoordinate, + targetItemActual: configs.debug && (targetItem?.$TST.element?.offsetTop + Size.getScrollBoxRect().top), + targetItemSize, + area: beforeOrAfterDropAreaSize, + before: `< ${targetItemCoordinate + beforeOrAfterDropAreaSize}`, + after: `> ${targetItemCoordinate + targetItemSize - beforeOrAfterDropAreaSize}`, + }); + */ + const shouldInvertArea = onFaviconizedTab && isRTL(); + if (eventCoordinate < targetItemCoordinate + beforeOrAfterDropAreaSize) { + info.dropPosition = shouldInvertArea ? kDROP_AFTER : kDROP_BEFORE; + info.insertBefore = info.firstTargetableItem; + } + else if (dropAreasCount == 2 || + eventCoordinate > targetItemCoordinate + targetItemSize - beforeOrAfterDropAreaSize) { + info.dropPosition = shouldInvertArea ? kDROP_BEFORE : kDROP_AFTER; + info.insertAfter = info.lastTargetableItem; + } + else { + info.dropPosition = kDROP_ON_SELF; + } + + switch (info.dropPosition) { + case kDROP_ON_SELF: { + log('drop position = on ', info.targetItem.id); + const insertAt = configs.insertDroppedTabsAt == Constants.kINSERT_INHERIT ? configs.insertNewChildAt : configs.insertDroppedTabsAt; + info.action = Constants.kACTION_ATTACH; + info.parent = info.substanceTargetItem || targetItem; + info.insertBefore = insertAt == Constants.kINSERT_TOP ? + (info.parent?.$TST.firstChild || info.parent?.$TST.unsafeNextTab /* instead of nearestVisibleFollowingTab, to avoid placing the tab after hidden tabs (too far from the target) */) : + (info.parent?.$TST.nextSiblingTab || info.parent?.$TST.unsafeNearestFollowingForeignerTab /* instead of nearestFollowingForeignerTab, to avoid placing the tab after hidden tabs (too far from the target) */); + info.insertAfter = insertAt == Constants.kINSERT_TOP ? + info.parent : + (info.parent.$TST.lastDescendant || info.parent); + if ((info.draggedItem && // we cannot drop pinned tab on unpinned tab, or unpinned tab on pinned tab + !!info.draggedItem.pinned != !!targetItem.pinned && + !info.substanceTargetItem) || + (info.draggedItem?.type == TreeItem.TYPE_GROUP && // we cannot drop group on tab + targetItem.type == TreeItem.TYPE_TAB)) + info.dropPosition = kDROP_IMPOSSIBLE; + if (info.draggedItem && + info.insertBefore == info.draggedItem) // failsafe + info.insertBefore = insertAt == Constants.kINSERT_TOP ? + info.draggedItem.$TST.unsafeNextTab : + (info.draggedItem.$TST.nextSiblingTab || + info.draggedItem.$TST.unsafeNearestFollowingForeignerTab); + const isRightside = document.documentElement.classList.contains('right'); + const substanceElement = targetItem?.$TST?.element?.substanceElement; + if (isRTL() == isRightside) { + const neck = substanceElement.offsetLeft + Size.getFavIconSize(); + info.inlineDropPosition = event.clientX < neck ? kDROP_HEAD : kDROP_TAIL; + } + else { + const neck = substanceElement.offsetLeft + substanceElement.offsetWidth - Size.getFavIconSize(); + info.inlineDropPosition = event.clientX > neck ? kDROP_HEAD : kDROP_TAIL; + } + if (configs.debug) + log(' calculated info: ', info); + }; break; + + case kDROP_BEFORE: { + log('drop position = before ', info.targetItem.id); + const referenceItems = TreeBehavior.calculateReferenceItemsFromInsertionPosition(info.draggedItem, { + context: Constants.kINSERTION_CONTEXT_MOVED, + insertBefore: targetItem.$TST.firstMember || targetItem, + }); + if (referenceItems.parent) + info.parent = referenceItems.parent; + if (referenceItems.insertBefore) + info.insertBefore = referenceItems.insertBefore; + if (referenceItems.insertAfter) + info.insertAfter = referenceItems.insertAfter; + info.action = Constants.kACTION_MOVE | (info.parent ? Constants.kACTION_ATTACH : Constants.kACTION_DETACH ); + //if (info.insertBefore) + // log('insertBefore = ', dumpTab(info.insertBefore)); + if ((info.draggedItem && // we cannot drop pinned tab beteen unpinned tabs, or unpinned tab between pinned tabs + ((info.draggedItem.pinned && + targetItem.$TST.followsUnpinnedTab) || + (!info.draggedItem.pinned && + targetItem.pinned))) || + (info.draggedItem?.type == TreeItem.TYPE_GROUP && // we cannot drop group on its member + targetItem.type == TreeItem.TYPE_TAB && + targetItem.groupId == info.draggedItem.id)) + info.dropPosition = kDROP_IMPOSSIBLE; + if (configs.debug) + log(' calculated info: ', info); + }; break; + + case kDROP_AFTER: { + log('drop position = after ', info.targetItem.id); + const referenceItems = TreeBehavior.calculateReferenceItemsFromInsertionPosition(info.draggedItem, { + insertAfter: targetItem.$TST.lastMember || (targetItem.$TST.subtreeCollapsed && targetItem.$TST.lastDescendant || targetItem), + }); + if (referenceItems.parent) + info.parent = referenceItems.parent; + if (referenceItems.insertBefore) + info.insertBefore = referenceItems.insertBefore; + if (referenceItems.insertAfter) + info.insertAfter = referenceItems.insertAfter; + info.action = Constants.kACTION_MOVE | (info.parent ? Constants.kACTION_ATTACH : Constants.kACTION_DETACH ); + if (info.insertBefore) { + /* strategy + +----------------------------------------------------- + |[TARGET ] + | <= attach dragged tab to the parent of the target as its next sibling + | [DRAGGED] + +----------------------------------------------------- + */ + if (info.draggedItem && + info.draggedItem.$TST && + info.draggedItem.$TST.nearestVisibleFollowingTab && + info.draggedItem.$TST.nearestVisibleFollowingTab.id == info.insertBefore.id) { + log('special case: promote tab'); + info.action = Constants.kACTION_MOVE | Constants.kACTION_ATTACH; + info.parent = targetItem.$TST.parent; + let insertBefore = targetItem.$TST.nextSiblingTab; + let ancestor = info.parent; + while (ancestor && !insertBefore) { + insertBefore = ancestor.$TST.nextSiblingTab; + ancestor = ancestor.$TST.parent; + } + info.insertBefore = insertBefore; + info.insertAfter = targetItem.$TST.lastDescendant; + } + } + if ((info.draggedItem && // we cannot drop pinned tab beteen unpinned tabs, or unpinned tab between pinned tabs + ((info.draggedItem.pinned && + !targetItem.pinned) || + (!info.draggedItem.pinned && + targetItem.$TST.precedesPinnedTab))) || + (info.draggedItem?.type == TreeItem.TYPE_GROUP && // we cannot drop group on its member + targetItem.type == TreeItem.TYPE_TAB && + targetItem.groupId == info.draggedItem.id)) + info.dropPosition = kDROP_IMPOSSIBLE; + if (configs.debug) + log(' calculated info: ', info); + }; break; + } + + return info; +} +function getDropEffectFromDropAction(actionInfo) { + if (!actionInfo.canDrop) + return 'none'; + if (actionInfo.dragData && + actionInfo.dragData.instanceId != mInstanceId) + return 'copy'; + if (!actionInfo.draggedItem) + return 'link'; + if (actionInfo.isCopyAction) + return 'copy'; + return 'move'; +} + +const mDropPositionHolderItems = new Set(); + +export function clearDropPosition() { + for (const tab of mDropPositionHolderItems) { + tab.$TST.removeAttribute(kDROP_POSITION); + tab.$TST.removeAttribute(kINLINE_DROP_POSITION); + tab.$TST.removeAttribute(kNEXT_GROUP_COLOR); + } + mDropPositionHolderItems.clear(); + configs.lastDragOverSidebarOwnerWindowId = null; +} + +export function clearDraggingItemsState() { + for (const tab of Tab.getDraggingTabs(TabsStore.getCurrentWindowId(), { iterator: true })) { + tab.$TST.removeState(Constants.kTAB_STATE_DRAGGING); + TabsStore.removeDraggingTab(tab); + } + for (const group of TabsStore.windows.get(TabsStore.getCurrentWindowId()).tabGroups.values()) { + if (group.$TST.states.has(Constants.kTAB_STATE_DRAGGING)) { + group.$TST.removeState(Constants.kTAB_STATE_DRAGGING); + } + } +} + +export function clearDraggingState() { + const win = TabsStore.windows.get(TabsStore.getCurrentWindowId()); + win.containerClassList.remove(Constants.kTABBAR_STATE_TAB_DRAGGING); + win.pinnedContainerClassList.remove(Constants.kTABBAR_STATE_TAB_DRAGGING); + document.documentElement.classList.remove(Constants.kTABBAR_STATE_TAB_DRAGGING); + document.documentElement.classList.remove(Constants.kTABBAR_STATE_LINK_DRAGGING); +} + +function isDraggingAllActiveTabs(tab) { + const draggingTabsCount = TabsStore.draggingTabsInWindow.get(tab.windowId).size; + const allTabsCount = TabsStore.windows.get(tab.windowId).tabs.size; + return draggingTabsCount == allTabsCount; +} + +function collapseAutoExpandedItemsWhileDragging() { + if (mLongHoverExpandedTabs.length > 0 && + configs.autoExpandOnLongHoverRestoreIniitalState) { + for (const tab of mLongHoverExpandedTabs) { + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_SET_SUBTREE_COLLAPSED_STATE, + tabId: tab.id, + collapsed: false, + justNow: true, + stack: configs.debug && new Error().stack + }); + } + } + mLongHoverExpandedTabs = []; +} + +async function handleDroppedNonTreeItems(event, dropActionInfo) { + event.stopPropagation(); + + const uris = await RetrieveURL.fromDragEvent(event); + // uris.forEach(uRI => { + // if (uRI.indexOf(Constants.kURI_BOOKMARK_FOLDER) != 0) + // securityCheck(uRI, event); + // }); + log('handleDroppedNonTreeItems: ', uris); + + const dragOverItem = dropActionInfo.dragOverItem; + if (dragOverItem && + dropActionInfo.dropPosition == kDROP_ON_SELF && + !dragOverItem.pinned) { + const behavior = await getDroppedLinksOnTabBehavior(); + if (behavior <= Constants.kDROPLINK_ASK) + return; + if (behavior & Constants.kDROPLINK_LOAD) { + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_ACTIVATE_TAB, + tabId: dropActionInfo.dragOverItem.id, + byMouseOperation: true + }); + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_LOAD_URI, + uri: uris.shift(), + tabId: dropActionInfo.dragOverItem.id + }); + } + } + const active = !!configs.simulateTabsLoadInBackgroundInverted; + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_NEW_TABS, + uris, + windowId: TabsStore.getCurrentWindowId(), + parentId: dropActionInfo.parent?.id, + insertBeforeId: dropActionInfo.insertBefore?.id, + insertAfterId: dropActionInfo.insertAfter?.id, + active, + discarded: !active && configs.tabsLoadInBackgroundDiscarded, + }); +} + +async function getDroppedLinksOnTabBehavior() { + let behavior = configs.dropLinksOnTabBehavior; + if (behavior != Constants.kDROPLINK_ASK) + return behavior; + + const confirm = new RichConfirm({ + message: browser.i18n.getMessage('dropLinksOnTabBehavior_message'), + buttons: [ + browser.i18n.getMessage('dropLinksOnTabBehavior_load'), + browser.i18n.getMessage('dropLinksOnTabBehavior_newtab') + ], + checkMessage: browser.i18n.getMessage('dropLinksOnTabBehavior_save') + }); + const result = await confirm.show(); + switch (result.buttonIndex) { + case 0: + behavior = Constants.kDROPLINK_LOAD; + break; + case 1: + behavior = Constants.kDROPLINK_NEWTAB; + break; + default: + return result.buttonIndex; + } + if (result.checked) + configs.dropLinksOnTabBehavior = behavior; + return behavior; +} + + +/* DOM event listeners */ + +let mFinishCanceledDragOperation; +let mCurrentDragDataForExternalsId = null; +let mCurrentDragDataForExternals = null; + +function onDragStart(event, options = {}) { + log('onDragStart: start ', event, options); + clearDraggingItemsState(); // clear previous state anyway + if (configs.enableWorkaroundForBug1548949) + configs.workaroundForBug1548949DroppedItems = ''; + + let draggedItem = options.item || EventUtils.getTreeItemFromEvent(event); + let behavior = 'behavior' in options ? options.behavior : + event.shiftKey ? configs.tabDragBehaviorShift : + configs.tabDragBehavior; + + if (draggedItem?.$TST.subtreeCollapsed || + draggedItem?.$TST.group) + behavior |= Constants.kDRAG_BEHAVIOR_ENTIRE_TREE; + + mCurrentDragDataForExternalsId = `${parseInt(Math.random() * 65000)}-${Date.now()}`; + mCurrentDragDataForExternals = {}; + + const originalTarget = EventUtils.getElementOriginalTarget(event); + const extraTabContentsDragData = JSON.parse(originalTarget?.dataset?.dragData || 'null'); + log('onDragStart: extraTabContentsDragData = ', extraTabContentsDragData); + let dataOverridden = false; + if (extraTabContentsDragData) { + const dataSet = detectOverrideDragDataSet(extraTabContentsDragData, event); + log('onDragStart: detected override data set = ', dataSet); + /* + expected drag data format: + Tab: + { type: 'tab', + data: { asTree: (boolean), + allowDetach: (boolean, will detach the tab to new window), + allowLink: (boolean, will create link/bookmark from the tab) }} + other arbitrary types: + { type: 'text/plain', + data: 'something text', + effectAllowed: 'copy' } + { type: 'text/x-moz-url', + data: 'http://example.com/\nExample Link', + effectAllowed: 'copyMove' } + ... + */ + let tabIsGiven = false; + for (const data of dataSet) { + if (!data) + continue; + switch (data.type) { + case 'tab': + if (data.data.id) { + const tab = Tab.get(data.data.id); + if (tab) { + tabIsGiven = true; + draggedItem = tab; + behavior = data.data.allowMove === false ? Constants.kDRAG_BEHAVIOR_NONE : Constants.kDRAG_BEHAVIOR_MOVE; + if (data.data.allowDetach) + behavior |= Constants.kDRAG_BEHAVIOR_TEAR_OFF; + if (data.data.allowLink) + behavior |= Constants.kDRAG_BEHAVIOR_ALLOW_BOOKMARK; + if (data.data.asTree) + behavior |= Constants.kDRAG_BEHAVIOR_ENTIRE_TREE; + } + } + break; + default: { + const dt = event.dataTransfer; + dt.effectAllowed = data.effectAllowed || 'copy'; + const type = String(data.type); + const stringData = String(data.data); + dt.setData(type, stringData); + //*** We need to sanitize drag data from helper addons, because + //they can have sensitive data... + //mCurrentDragDataForExternals[type] = stringData; + dataOverridden = true; + }; break; + } + } + if (!tabIsGiven && dataOverridden) + return; + } + + const allowBookmark = !!(behavior & Constants.kDRAG_BEHAVIOR_ALLOW_BOOKMARK); + const asTree = !!(behavior & Constants.kDRAG_BEHAVIOR_ENTIRE_TREE); + const dragData = getDragDataFromOneItem(draggedItem, { asTree }); + dragData.individualOnOutside = dragData.item && !dragData.item.$TST.multiselected && !asTree + dragData.behavior = behavior; + if (!dragData.item) { + log('onDragStart: canceled / no dragged item from drag data'); + return; + } + log('dragData: ', dragData); + + if (!(behavior & Constants.kDRAG_BEHAVIOR_MOVE) && + !(behavior & Constants.kDRAG_BEHAVIOR_TEAR_OFF) && + !allowBookmark) { + log('ignore drag action because it can do nothing'); + event.stopPropagation(); + event.preventDefault(); + return; + } + + const item = dragData.item; + const mousedown = EventUtils.getLastMousedown(event.button); + + if (mousedown && + mousedown.detail.lastInnerScreenY != window.mozInnerScreenY) { + log('ignore accidental drag from updated visual gap'); + event.stopPropagation(); + event.preventDefault(); + return; + } + + if (mousedown?.expired) { + log('onDragStart: canceled / expired'); + event.stopPropagation(); + event.preventDefault(); + mLastDragEnteredTarget = item.$TST.element || null; + const startOnClosebox = mDragTargetIsClosebox = mousedown.detail.closebox; + if (startOnClosebox) + mLastDragEnteredTarget = item.$TST.element?.closeBox || null; + const windowId = TabsStore.getCurrentWindowId(); + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_TAB_DRAGSTART, + item, + tab: item.$TST.tab, // for backward compatibility + window: windowId, + windowId, + startOnClosebox + }, { tabProperties: ['item', 'tab'] }).catch(_error => {}); + window.addEventListener('mouseover', onTSTAPIDragEnter, { capture: true }); + window.addEventListener('mouseout', onTSTAPIDragExit, { capture: true }); + document.body.setCapture(false); + mCapturingForDragging = true; + return; + } + + // dragging on clickable element will be expected to cancel the operation + if (EventUtils.isEventFiredOnClosebox(options.item?.$TST.element || event) || + EventUtils.isEventFiredOnClickable(options.item?.$TST.element || event)) { + log('onDragStart: canceled / on undraggable element'); + event.stopPropagation(); + event.preventDefault(); + return; + } + + EventUtils.cancelHandleMousedown(); + + mDraggingOnSelfWindow = true; + mDraggingOnDraggedItems = true; + mLastDropPosition = mLastInlineDropPosition = null; + + const dt = event.dataTransfer; + dt.effectAllowed = 'copyMove'; + + const sanitizedDragData = sanitizeDragData(dragData); + dt.setData(kTREE_DROP_TYPE, JSON.stringify(sanitizedDragData)); + + log(`onDragStart: starting drag session ${sanitizedDragData.sessionId}`); + + // Because addon cannot read drag data across private browsing mode, + // we need to share detailed information of dragged items in different way! + mCurrentDragData = sanitizedDragData; + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_BROADCAST_CURRENT_DRAG_DATA, + windowId: TabsStore.getCurrentWindowId(), + dragData: sanitizedDragData + }).catch(ApiTabs.createErrorSuppressor()); + + if (!dataOverridden && + dragData.tab) { + const urls = []; + const mozUrl = []; + const urlList = []; + for (const draggedTab of dragData.tabs) { + draggedTab.$TST.addState(Constants.kTAB_STATE_DRAGGING); + TabsStore.addDraggingTab(draggedTab); + if (!dragData.individualOnOutside || + mozUrl.length == 0) { + urls.push(draggedTab.url); + mozUrl.push(`${draggedTab.url}\n${draggedTab.title}`); + urlList.push(`#${draggedTab.title}\n${draggedTab.url}`); + } + } + mCurrentDragDataForExternals[RetrieveURL.kTYPE_PLAIN_TEXT] = urls.join('\n'); + mCurrentDragDataForExternals[RetrieveURL.kTYPE_X_MOZ_URL] = mozUrl.join('\n'); + mCurrentDragDataForExternals[RetrieveURL.kTYPE_URI_LIST] = urlList.join('\n'); + if (allowBookmark) { + log('set kTYPE_PLAIN_TEXT ', mCurrentDragDataForExternals[RetrieveURL.kTYPE_PLAIN_TEXT]); + dt.setData(RetrieveURL.kTYPE_PLAIN_TEXT, mCurrentDragDataForExternals[RetrieveURL.kTYPE_PLAIN_TEXT]); + log('set kTYPE_X_MOZ_URL ', mCurrentDragDataForExternals[RetrieveURL.kTYPE_X_MOZ_URL]); + dt.setData(RetrieveURL.kTYPE_X_MOZ_URL, mCurrentDragDataForExternals[RetrieveURL.kTYPE_X_MOZ_URL]); + log('set kTYPE_URI_LIST ', mCurrentDragDataForExternals[RetrieveURL.kTYPE_URI_LIST]); + dt.setData(RetrieveURL.kTYPE_URI_LIST, mCurrentDragDataForExternals[RetrieveURL.kTYPE_URI_LIST]); + } + } + { + const dragDataType = `${kTYPE_ADDON_DRAG_DATA}${mCurrentDragDataForExternalsId}`; + const dragDataContent = JSON.stringify(mCurrentDragDataForExternals); + try { + dt.setData(dragDataType, dragDataContent); + } + catch(error) { + console.error(error); + console.log(`Failed to set drag data with the type ${dragDataType}:`, dragDataContent); + } + } + + if (item.$TST.element) { + // We set negative offsets to get more visibility about drop targets. + // See also: https://github.com/piroor/treestyletab/issues/2826 + const offset = -16; + dt.setDragImage(item.$TST.element, offset, offset); + } + + const win = TabsStore.windows.get(TabsStore.getCurrentWindowId()); + win.containerClassList.add(Constants.kTABBAR_STATE_TAB_DRAGGING); + win.pinnedContainerClassList.add(Constants.kTABBAR_STATE_TAB_DRAGGING); + document.documentElement.classList.add(Constants.kTABBAR_STATE_TAB_DRAGGING); + + if (!('behavior' in options) && + configs.showTabDragBehaviorNotification) { + const invertedBehavior = event.shiftKey ? configs.tabDragBehavior : configs.tabDragBehaviorShift; + const count = dragData.tabs.length; + const currentResult = getTabDragBehaviorNotificationMessageType(behavior, count); + const invertedResult = getTabDragBehaviorNotificationMessageType(invertedBehavior, count); + if (currentResult || invertedResult) { + const invertSuffix = event.shiftKey ? 'without_shift' : 'with_shift'; + Notifications.add('tab-drag-behavior-description', { + message: [ + currentResult && browser.i18n.getMessage(`tabDragBehaviorNotification_message_base`, [ + browser.i18n.getMessage(`tabDragBehaviorNotification_message_${currentResult}`)]), + invertedResult && browser.i18n.getMessage(`tabDragBehaviorNotification_message_inverted_base_${invertSuffix}`, [ + browser.i18n.getMessage(`tabDragBehaviorNotification_message_${invertedResult}`)]), + ].join('\n'), + onCreated(notification) { + notification.style.animationDuration = !shouldApplyAnimation() ? + 0 : + browser.i18n.getMessage(`tabDragBehaviorNotification_message_duration_${currentResult && invertedResult ? 'both' : 'single'}`) + }, + }); + } + } + + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_NATIVE_TAB_DRAGSTART, + item, + tab: item.$TST.tab, // for backward compatibility + windowId: TabsStore.getCurrentWindowId() + }, { tabProperties: ['item', 'tab'] }).catch(_error => {}); + + updateLastDragEventCoordinates(event); + // Don't store raw URLs to save privacy! + sha1sum(dragData.tabs.map(tab => tab.url).join('\n')).then(digest => { + configs.lastDraggedTabs = { + tabIds: dragData.tabs.map(tab => tab.id), + urlsDigest: digest + }; + }); + + log('onDragStart: started'); +} +onDragStart = EventUtils.wrapWithErrorHandler(onDragStart); + +/* acceptable input: + { + "default": { type: ..., data: ... }, + "Ctrl": { type: ..., data: ... }, + "MacCtrl": { type: ..., data: ... }, + "Ctrl+Shift": { type: ..., data: ... }, + "Alt-Shift": { type: ..., data: ... }, + ... + } +*/ +function detectOverrideDragDataSet(dataSet, event) { + if (Array.isArray(dataSet)) + return dataSet.map(oneDataSet => detectOverrideDragDataSet(oneDataSet, event)).flat(); + + if ('type' in dataSet) + return [dataSet]; + + const keys = []; + if (event.altKey) + keys.push('alt'); + if (event.ctrlKey) { + if (isMacOS()) + keys.push('macctrl'); + else + keys.push('ctrl'); + } + if (event.metaKey) { + if (isMacOS()) + keys.push('command'); + else + keys.push('meta'); + } + if (event.shiftKey) + keys.push('shift'); + const findKey = keys.sort().join('+') || 'default'; + + for (const key of Object.keys(dataSet)) { + const normalizedKey = key.split(/[-\+]/).filter(part => !!part).sort().join('+').toLowerCase(); + if (normalizedKey != findKey) + continue; + if (Array.isArray(dataSet[key])) + return dataSet[key]; + else + return [dataSet[key]]; + } + return []; +} + +function getTabDragBehaviorNotificationMessageType(behavior, count) { + if (behavior & Constants.kDRAG_BEHAVIOR_ENTIRE_TREE && count > 1) { + if (behavior & Constants.kDRAG_BEHAVIOR_ALLOW_BOOKMARK) + return 'tree_bookmark'; + else if (behavior & Constants.kDRAG_BEHAVIOR_TEAR_OFF) + return 'tree_tearoff'; + else + return ''; + } + else { + if (behavior & Constants.kDRAG_BEHAVIOR_ALLOW_BOOKMARK) + return 'tab_bookmark'; + else if (behavior & Constants.kDRAG_BEHAVIOR_TEAR_OFF) + return 'tab_tearoff'; + else + return ''; + } +} + +let mLastDragOverTimestamp = null; +let mDelayedClearDropPosition = null; + +function onDragOver(event) { + const dt = event.dataTransfer; + if (dt.types.length == 0) { + // On Linux, unexpected invalid dragover events can be fired on various triggers unrelated to drag and drop. + // TST ignores such events as a workaround. + // See also: https://github.com/piroor/treestyletab/issues/3374 + log('onDragOver: ignore invalid dragover event'); + return; + } + + if (mFinishCanceledDragOperation) { + clearTimeout(mFinishCanceledDragOperation); + mFinishCanceledDragOperation = null; + } + + if (!isLinux()) { + if (mDelayedClearDropPosition) + clearTimeout(mDelayedClearDropPosition); + mDelayedClearDropPosition = setTimeout(() => { + mDelayedClearDropPosition = null; + clearDropPosition(); + }, 250); + } + + event.preventDefault(); // this is required to override default dragover actions! + Scroll.autoScrollOnMouseEvent(event); + + updateLastDragEventCoordinates(event); + + // reduce too much handling of too frequent dragover events... + const now = Date.now(); + if (now - (mLastDragOverTimestamp || 0) < configs.minimumIntervalToProcessDragoverEvent) + return; + mLastDragOverTimestamp = now; + + const info = getDropAction(event); + + let dragData = dt.getData(kTREE_DROP_TYPE); + dragData = (dragData && JSON.parse(dragData)) || mCurrentDragData; + const sessionId = dragData?.sessionId || ''; + log(`onDragOver: sessionId=${sessionId}, types=${dt.types}, dropEffect=${dt.dropEffect}, effectAllowed=${dt.effectAllowed}, item=`, dragData?.item); + + if (isEventFiredOnItemDropBlocker(event) || + !info.canDrop) { + log(`onDragOver: not droppable sessionId=${sessionId}`); + dt.dropEffect = 'none'; + if (mLastDropPosition) + clearDropPosition(); + mLastDropPosition = mLastInlineDropPosition = null; + return; + } + + if (EventUtils.isEventFiredOnNewTabButton(event)) { + log(`onDragOver: dragging something on the new tab button sessionId=${sessionId}`); + dt.dropEffect = 'move'; + if (mLastDropPosition) + clearDropPosition(); + mLastDropPosition = mLastInlineDropPosition = null; + return; + } + + let dropPositionTargetItem = info.targetItem; + if (dropPositionTargetItem?.$TST?.collapsed) + dropPositionTargetItem = info.targetItem.$TST.nearestVisiblePrecedingTab || info.targetItem; + if (!dropPositionTargetItem) { + log(`onDragOver: no drop target item sessionId=${sessionId}`); + dt.dropEffect = 'none'; + mLastDropPosition = mLastInlineDropPosition = null; + return; + } + + const dropPosition = `${dropPositionTargetItem.id}:${info.dropPosition}`; + const inlineDropPosition = `${dropPositionTargetItem.id}:${info.inlineDropPosition}`; + if (!info.draggedItem || + dropPositionTargetItem.id != info.draggedItem.id || + dropPosition != mLastDropPosition || + inlineDropPosition != mLastInlineDropPosition) { + if (dropPosition == mLastDropPosition && + inlineDropPosition == mLastInlineDropPosition) { + log(`onDragOver: no move, sessionId=${sessionId}`); + return; + } + clearDropPosition(); + dropPositionTargetItem.$TST.setAttribute(kDROP_POSITION, info.dropPosition); + dropPositionTargetItem.$TST.setAttribute(kINLINE_DROP_POSITION, info.inlineDropPosition); + mDropPositionHolderItems.add(dropPositionTargetItem); + if (info.canCreateGroup) { + dropPositionTargetItem.$TST.setAttribute(kNEXT_GROUP_COLOR, dragData.nextGroupColor); + } + const substanceTargetItem = info.substanceTargetItem; + if (substanceTargetItem && + info.dropPosition == kDROP_ON_SELF) { + substanceTargetItem.$TST.setAttribute(kDROP_POSITION, info.dropPosition); + substanceTargetItem.$TST.setAttribute(kINLINE_DROP_POSITION, info.inlineDropPosition); + mDropPositionHolderItems.add(substanceTargetItem); + } + mLastDropPosition = dropPosition; + mLastInlineDropPosition = inlineDropPosition; + log(`onDragOver: set drop position to ${dropPosition}, sessionId=${sessionId}`); + } + else { + mLastDropPosition = mLastInlineDropPosition = null; + } +} +onDragOver = EventUtils.wrapWithErrorHandler(onDragOver); + +function isEventFiredOnItemDropBlocker(event) { + let node = event.target; + if (node.nodeType != Node.ELEMENT_NODE) + node = node.parentNode; + return node && !!node.closest('.item-drop-blocker'); +} + +function onDragEnter(event) { + configs.lastDragOverSidebarOwnerWindowId = TabsStore.getCurrentWindowId(); + + mDraggingOnSelfWindow = true; + + const info = getDropAction(event); + try { + const enteredItem = EventUtils.getTreeItemFromEvent(event); + const leftItem = SidebarItems.getItemFromDOMNode(event.relatedTarget); + if (leftItem != enteredItem) { + mDraggingOnDraggedItems = ( + info.dragData && + info.dragData.tabs.some(tab => tab.id == enteredItem.id) + ); + } + const win = TabsStore.windows.get(TabsStore.getCurrentWindowId()); + win.containerClassList.add(Constants.kTABBAR_STATE_TAB_DRAGGING); + win.pinnedContainerClassList.add(Constants.kTABBAR_STATE_TAB_DRAGGING); + document.documentElement.classList.add(Constants.kTABBAR_STATE_TAB_DRAGGING); + } + catch(_e) { + } + + const dt = event.dataTransfer; + dt.dropEffect = info.dropEffect; + if (info.dropEffect == 'link') + document.documentElement.classList.add(Constants.kTABBAR_STATE_LINK_DRAGGING); + + updateLastDragEventCoordinates(event); + + if (!info.canDrop || + !info.dragOverItem) + return; + + reserveToProcessLongHover.cancel(); + + if (info.draggedItem && + info.dragOverItem.id == info.draggedItem.id) + return; + + reserveToProcessLongHover({ + dragOverItemId: info.targetItem?.id, + draggedItemId: info.draggedItem?.id, + dropEffect: info.dropEffect, + }); +} +onDragEnter = EventUtils.wrapWithErrorHandler(onDragEnter); + +function reserveToProcessLongHover({ dragOverItemId, draggedItemId, dropEffect }) { + mLongHoverTimerNext = setTimeout(() => { + if (!mLongHoverTimerNext) + return; // already canceled + mLongHoverTimerNext = null; + mLongHoverTimer = setTimeout(async () => { + if (!mLongHoverTimer) + return; // already canceled + + mLongHoverTimer = null; + log('reservedProcessLongHover: ', { dragOverItemId, draggedItemId, dropEffect }); + + const dragOverItem = Tab.get(dragOverItemId); + if (!dragOverItem || + dragOverItem.$TST.getAttribute(kDROP_POSITION) != 'self') + return; + + // auto-switch for staying on tabs + if (!dragOverItem.active && + dropEffect == 'link') { + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_ACTIVATE_TAB, + tabId: dragOverItem.id, + byMouseOperation: true + }); + } + + if (!configs.autoExpandOnLongHover || + !dragOverItem || + !dragOverItem.$TST.isAutoExpandable) + return; + + // auto-expand for staying on a parent + if (configs.autoExpandIntelligently) { + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_SET_SUBTREE_COLLAPSED_STATE_INTELLIGENTLY_FOR, + tabId: dragOverItem.id + }); + } + else { + if (!mLongHoverExpandedTabs.includes(dragOverItemId)) + mLongHoverExpandedTabs.push(dragOverItemId); + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_SET_SUBTREE_COLLAPSED_STATE, + tabId: dragOverItem.id, + collapsed: false, + stack: configs.debug && new Error().stack + }); + } + }, configs.autoExpandOnLongHoverDelay); + }, 0); +} +reserveToProcessLongHover.cancel = function() { + if (mLongHoverTimer) { + clearTimeout(mLongHoverTimer); + mLongHoverTimer = null; + } + if (mLongHoverTimerNext) { + clearTimeout(mLongHoverTimerNext); + mLongHoverTimerNext = null; + } +}; + +function onDragLeave(event) { + if (configs.lastDragOverSidebarOwnerWindowId == TabsStore.getCurrentWindowId()) + configs.lastDragOverSidebarOwnerWindowId = null; + + let leftFromTabBar = false; + try { + const info = getDropAction(event); + const leftItem = EventUtils.getTreeItemFromEvent(event); + const enteredItem = SidebarItems.getItemFromDOMNode(event.relatedTarget); + if (leftItem != enteredItem) { + if (info.dragData && + info.dragData.items.some(item => item.id == leftItem.id) && + (!enteredItem || + !info.dragData.items.every(item => item.id == enteredItem.id))) { + onDragLeave.delayedLeftFromDraggedItems = setTimeout(() => { + delete onDragLeave.delayedLeftFromDraggedItems; + mDraggingOnDraggedItems = false; + }, 10); + } + else { + leftFromTabBar = !enteredItem || enteredItem.windowId != TabsStore.getCurrentWindowId(); + if (onDragLeave.delayedLeftFromDraggedItems) { + clearTimeout(onDragLeave.delayedLeftFromDraggedItems); + delete onDragLeave.delayedLeftFromDraggedItems; + } + } + } + } + catch(_e) { + leftFromTabBar = true; + } + + if (leftFromTabBar) { + onDragLeave.delayedLeftFromTabBar = setTimeout(() => { + delete onDragLeave.delayedLeftFromTabBar; + mDraggingOnSelfWindow = false; + mDraggingOnDraggedItems = false; + clearDropPosition(); + clearDraggingState(); + mLastDropPosition = null; + mLastInlineDropPosition = null; + reserveToProcessLongHover.cancel(); + }, 10); + } + else if (onDragLeave.delayedLeftFromTabBar) { + clearTimeout(onDragLeave.delayedLeftFromTabBar); + delete onDragLeave.delayedLeftFromTabBar; + } + + updateLastDragEventCoordinates(event); + clearTimeout(mLongHoverTimer); + mLongHoverTimer = null; +} +onDragLeave = EventUtils.wrapWithErrorHandler(onDragLeave); + +function onDrop(event) { + setTimeout(() => { + collapseAutoExpandedItemsWhileDragging(); + // Don't clear flags immediately, because they are referred by following operations in this function. + finishDrag('onDrop'); + }, 0); + + const dropActionInfo = getDropAction(event); + + let dragData = event.dataTransfer.getData(kTREE_DROP_TYPE); + dragData = (dragData && JSON.parse(dragData)) || mCurrentDragData; + const sessionId = dragData?.sessionId || ''; + log(`onDrop ${sessionId}`, dropActionInfo, event.dataTransfer); + + if (!dropActionInfo.canDrop) { + log('undroppable'); + return; + } + + const dt = event.dataTransfer; + if (dt.dropEffect != 'link' && + dt.dropEffect != 'move' && + dropActionInfo.dragData && + !dropActionInfo.dragData.item) { + log('invalid drop'); + return; + } + + // We need to cancel the drop event explicitly to prevent Firefox tries to load the dropped URL to the tab itself. + // This is required to use "ext+ws:tabbar" in a regular tab. + // See also: https://github.com/piroor/treestyletab/issues/3056 + event.preventDefault(); + + if (dropActionInfo.dragData && + dropActionInfo.dragData.item) { + log('there are dragged items: ', () => dropActionInfo.dragData.items.map(dumpTab)); + if (configs.enableWorkaroundForBug1548949) { + configs.workaroundForBug1548949DroppedItems = dropActionInfo.dragData.items.map(item => `${mInstanceId}/${item.id}`).join('\n'); + log('workaround for bug 1548949: setting last dropped items: ', configs.workaroundForBug1548949DroppedItems); + } + const { draggedItems, structure, insertBefore, insertAfter } = sanitizeDraggedItems({ + draggedItems: dropActionInfo.dragData.items, + structure: dropActionInfo.dragData.structure, + insertBefore: dropActionInfo.insertBefore, + insertAfter: dropActionInfo.insertAfter, + parent: dropActionInfo.parent, + isCopy: dt.dropEffect == 'copy', + }); + const fromOtherProfile = dropActionInfo.dragData.instanceId != mInstanceId; + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_PERFORM_TABS_DRAG_DROP, + windowId: dropActionInfo.dragData.windowId, + items: draggedItems.map(item => item?.$TST?.sanitized || item), + droppedOn: dropActionInfo.sanitizedDropOnTargetItem, + droppedBefore: dropActionInfo.sanitizedDropBeforeTargetItem, + droppedAfter: dropActionInfo.sanitizedDropAfterTargetItem, + groupId: dropActionInfo.groupId, + structure, + action: dropActionInfo.action, + allowedActions: dropActionInfo.dragData.behavior, + attachToId: dropActionInfo.parent?.id, + insertBefore: insertBefore?.$TST?.sanitized || insertBefore, + insertAfter: insertAfter?.$TST?.sanitized || insertAfter, + destinationWindowId: TabsStore.getCurrentWindowId(), + duplicate: !fromOtherProfile && dt.dropEffect == 'copy', + nextGroupColor: dropActionInfo.dragData.nextGroupColor, + canCreateGroup: dropActionInfo.canCreateGroup, + import: fromOtherProfile + }); + return; + } + + if (dt.types.includes(RetrieveURL.kTYPE_MOZ_TEXT_INTERNAL) && + configs.guessDraggedNativeTabs) { + const url = dt.getData(RetrieveURL.kTYPE_MOZ_TEXT_INTERNAL); + log(`finding native tabs with the dropped URL: ${url}`); + browser.tabs.query({ url, active: true }).then(async tabs => { + if (!tabs.length && url.includes('#')) { + log(`=> find again without the fragment part`); + tabs = await browser.tabs.query({ url: url.replace(/#.*$/, ''), active: true }); + if (!tabs.length) { + log('=> no such tabs, maybe dropped from other profile'); + handleDroppedNonTreeItems(event, dropActionInfo); + return; + } + } + log('=> possible dragged tabs: ', tabs); + tabs = tabs.sort((a, b) => b.lastAccessed - a.lastAccessed); + if (configs.enableWorkaroundForBug1548949) { + configs.workaroundForBug1548949DroppedItems = tabs.map(tab => `${mInstanceId}/${tab.id}`).join('\n'); + log('workaround for bug 1548949: setting last dropped tabs: ', configs.workaroundForBug1548949DroppedItems); + } + const recentTab = tabs[0]; + + const multiselectedTabs = await browser.tabs.query({ + windowId: recentTab.windowId, + highlighted: true, + }); + const structureFromMultiselectedTabs = (recentTab.windowId == TabsStore.getCurrentWindowId()) ? + TreeBehavior.getTreeStructureFromTabs(multiselectedTabs.map(tab => Tab.get(tab.id))) : + (await browser.runtime.sendMessage({ + type: Constants.kCOMMAND_PULL_TREE_STRUCTURE, + tabIds: multiselectedTabs.map(tab => tab.id), + })).structure; + log('maybe dragged tabs: ', multiselectedTabs, structureFromMultiselectedTabs); + + const { draggedItems, structure, insertBefore, insertAfter } = sanitizeDraggedItems({ + draggedItems: multiselectedTabs, + structure: structureFromMultiselectedTabs, + insertBefore: dropActionInfo.insertBefore, + insertAfter: dropActionInfo.insertAfter, + parent: dropActionInfo.parent, + isCopy: dt.dropEffect == 'copy', + }); + + const allowedActions = event.shiftKey ? + configs.tabDragBehaviorShift : + configs.tabDragBehavior; + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_PERFORM_TABS_DRAG_DROP, + windowId: recentTab.windowId, + items: draggedItems.map(item => item?.$TST?.sanitized || item), + droppedOn: dropActionInfo.sanitizedDropOnTargetItem, + droppedBefore: dropActionInfo.sanitizedDropBeforeTargetItem, + droppedAfter: dropActionInfo.sanitizedDropAfterTargetItem, + groupId: dropActionInfo.groupId, + structure, + action: dropActionInfo.action, + allowedActions, + attachToId: dropActionInfo.parent?.id, + insertBefore: insertBefore?.$TST?.sanitized || insertBefore, + insertAfter: insertAfter?.$TST?.sanitized || insertAfter, + destinationWindowId: TabsStore.getCurrentWindowId(), + duplicate: dt.dropEffect == 'copy', + nextGroupColor: dropActionInfo.dragData?.nextGroupColor, + canCreateGroup: dropActionInfo.canCreateGroup, + import: false + }); + }); + return; + } + + log('link or bookmark item is dropped'); + handleDroppedNonTreeItems(event, dropActionInfo); +} +onDrop = EventUtils.wrapWithErrorHandler(onDrop); + +function sanitizeDraggedItems({ draggedItems, structure, insertBefore, insertAfter, parent, isCopy }) { + const parentId = parent?.id; + log('sanitizeDraggedItems: ', () => ({ draggedItems: draggedItems.map(dumpTab), structure, insertBefore: dumpTab(insertBefore), insertAfter: dumpTab(insertAfter), parentId, isCopy })); + if (isCopy || + !configs.moveSoloTabOnDropParentToDescendant || + draggedItems.every(item => item.id != parentId)) + return { draggedItems, structure, insertBefore, insertAfter }; + + log('=> dropping parent to a descendant: partial attach mode'); + for (let i = draggedItems.length - 1; i > -1; i--) { + if (structure[i].parent < 0) + continue; + draggedItems.splice(i, 1); + structure.splice(i, 1); + } + insertBefore = parent?.$TST.nextSiblingTab; + insertAfter = parent; + return { draggedItems, structure, insertBefore, insertAfter }; +} + +async function onDragEnd(event) { + log('onDragEnd, ', { event, mDraggingOnSelfWindow, mDraggingOnDraggedItems, dropEffect: event.dataTransfer?.dropEffect }); + if (!mLastDragEventCoordinates) { + log('dragend is handled after finishDrag - already handled by ondrop handler.'); + return; + } + const lastDragEventCoordinatesX = mLastDragEventCoordinates.x; + const lastDragEventCoordinatesY = mLastDragEventCoordinates.y; + const lastDragEventCoordinatesTimestamp = mLastDragEventCoordinates.timestamp; + const droppedOnSidebarArea = !!configs.lastDragOverSidebarOwnerWindowId; + + let dragData = event.dataTransfer?.getData(kTREE_DROP_TYPE); + dragData = (dragData && JSON.parse(dragData)) || mCurrentDragData; + if (dragData) { + dragData.item = TreeItem.get(dragData.item) || dragData.item; + dragData.items = dragData.items && dragData.items.map(item => TreeItem.get(item) || item); + log(`onDragEnd: finishing drag session ${dragData.sessionId}`); + } + + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_NATIVE_TAB_DRAGEND, + windowId: TabsStore.getCurrentWindowId() + }).catch(_error => {}); + + // Don't clear flags immediately, because they are referred by following operations in this function. + setTimeout(finishDrag, 0, 'onDragEnd'); + + if (!dragData || + !(dragData.behavior & Constants.kDRAG_BEHAVIOR_TEAR_OFF)) + return; + + let handledBySomeone = event.dataTransfer?.dropEffect != 'none'; + + if (event.dataTransfer?.getData(RetrieveURL.kTYPE_URI_LIST)) { + log('do nothing by TST for dropping just for bookmarking or linking'); + return; + } + else if (configs.enableWorkaroundForBug1548949) { + // Due to the bug 1548949, "dropEffect" can become "move" even if no one + // actually handles the drop. Basically kTREE_DROP_TYPE is not processible + // by anyone except TST, so, we can treat the dropend as "dropped outside + // the sidebar" when all dragged tabs are exact same to last tabs dropped + // to a sidebar on this Firefox instance. + // The only one exception is the case: tabs have been dropped to a TST + // sidebar on any other Firefox instance. In this case tabs dropped to the + // foreign Firefox will become duplicated: imported to the foreign Firefox + // and teared off from the source window. This is clearly undesider + // behavior from misdetection, but I decide to ignore it because it looks + // quite rare case. + await wait(250); // wait until "workaroundForBug1548949DroppedItems" is synchronized + const draggedItems = dragData.items.map(item => `${mInstanceId}/${item.id}`).join('\n'); + const lastDroppedItems = configs.workaroundForBug1548949DroppedItems; + handledBySomeone = draggedItems == lastDroppedItems; + log('workaround for bug 1548949: detect dragged tabs are handled by me or not.', + { handledBySomeone, draggedItems, lastDroppedItems }); + configs.workaroundForBug1548949DroppedItems = null; + } + + if (event.dataTransfer?.mozUserCancelled || + handledBySomeone) { + log('dragged items are processed by someone: ', event.dataTransfer?.dropEffect); + return; + } + + if (droppedOnSidebarArea) { + log('dropped on the tab bar (from event): detaching is canceled'); + return; + } + + if (configs.ignoreTabDropNearSidebarArea) { + const windowX = window.mozInnerScreenX; + const windowY = window.mozInnerScreenY; + const windowW = window.innerWidth; + const windowH = window.innerHeight; + const offset = Scroll.getItemRect(dragData.item).height / 2; + const now = Date.now(); + log('dragend at: ', { + windowX, + windowY, + windowW, + windowH, + eventScreenX: event.screenX, + eventScreenY: event.screenY, + eventClientX: event.clientX, + eventClientY: event.clientY, + lastDragEventCoordinatesX, + lastDragEventCoordinatesY, + offset, + }); + if (event.screenX >= windowX - offset && + event.screenY >= windowY - offset && + event.screenX <= windowX + windowW + offset && + event.screenY <= windowY + windowH + offset) { + log('dropped near the tab bar (from coordinates): detaching is canceled'); + return; + } + // Workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1561879 + // On macOS sometimes drag gesture is canceled immediately with (0,0) coordinates. + // (This happens on Windows also.) + const delayFromLast = now - lastDragEventCoordinatesTimestamp; + const rawOffsetX = Math.abs(event.screenX - lastDragEventCoordinatesX); + const rawOffsetY = Math.abs(event.screenY - lastDragEventCoordinatesY); + log('check: ', { + now, + lastDragEventCoordinatesTimestamp, + delayFromLast, + maxDelay: configs.maximumDelayForBug1561879, + offset, + rawOffsetX, + rawOffsetY, + }); + if (event.screenX == 0 && + event.screenY == 0 && + // We need to accept intentional drag and drop at left edge of the screen. + // For safety, cancel only when the coordinates become (0,0) accidently from the bug. + delayFromLast < configs.maximumDelayForBug1561879 && + rawOffsetX > offset && + rawOffsetY > offset) { + log('dropped at unknown position: detaching is canceled'); + return; + } + } + + log('trying to detach item from window'); + event.stopPropagation(); + event.preventDefault(); + + if (dragData.tab) { + if (isDraggingAllActiveTabs(dragData.tab)) { + log('all tabs are dragged, so it is nonsence to tear off them from the window'); + return; + } + + const detachTabs = dragData.individualOnOutside ? [dragData.tab] : dragData.tabs; + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_NEW_WINDOW_FROM_TABS, + tabIds: detachTabs.map(tab => tab.id), + duplicate: EventUtils.isAccelKeyPressed(event), + left: event.screenX, + top: event.screenY, + }); + } + + if (dragData.item?.$TST.group) { + if (dragData.item?.$TST.members.length == TabsStore.windows.get(dragData.item.windowId).tabs.size) { + log('the last one group containing all tabs is dragged, so it is nonsence to tear off it from the window'); + return; + } + + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_NEW_WINDOW_FROM_NATIVE_TAB_GROUP, + windowId: dragData.item.windowId, + groupId: dragData.item.id, + duplicate: EventUtils.isAccelKeyPressed(event), + left: event.screenX, + top: event.screenY, + }); + } + +} +onDragEnd = EventUtils.wrapWithErrorHandler(onDragEnd); + +function finishDrag(trigger) { + log(`finishDrag from ${trigger || 'unknown'}`); + + Notifications.remove('tab-drag-behavior-description'); + + mDraggingOnSelfWindow = false; + + wait(100).then(() => { + mCurrentDragData = null; + mCurrentDragDataForExternalsId = null; + mCurrentDragDataForExternals = null; + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_BROADCAST_CURRENT_DRAG_DATA, + windowId: TabsStore.getCurrentWindowId(), + dragData: null + }).catch(ApiTabs.createErrorSuppressor()); + }); + + onFinishDrag(); +} + +function onFinishDrag() { + clearDraggingItemsState(); + clearDropPosition(); + mLastDropPosition = null; + mLastInlineDropPosition = null; + updateLastDragEventCoordinates(); + mLastDragOverTimestamp = null; + clearDraggingState(); + collapseAutoExpandedItemsWhileDragging(); + mDraggingOnSelfWindow = false; + mDraggingOnDraggedItems = false; + reserveToProcessLongHover.cancel(); +} + +function updateLastDragEventCoordinates(event = null) { + mLastDragEventCoordinates = !event ? null : { + x: event.screenX, + y: event.screenY, + timestamp: Date.now(), + }; +} + + +/* drag on tabs API */ + +const mDragExitTimeoutForTarget = new WeakMap(); + +function onTSTAPIDragEnter(event) { + Scroll.autoScrollOnMouseEvent(event); + const item = EventUtils.getTreeItemFromEvent(event); + if (!item) + return; + let target = item.$TST.element; + if (mDragTargetIsClosebox && EventUtils.isEventFiredOnClosebox(event)) + target = target && item.$TST.element.closeBox; + cancelDelayedTSTAPIDragExitOn(target); + if (item && + (!mDragTargetIsClosebox || + EventUtils.isEventFiredOnClosebox(event))) { + if (target != mLastDragEnteredTarget) { + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_TAB_DRAGENTER, + item, + tab: item.$TST.tab, // for backward compatibility + window: item.windowId, + windowId: item.windowId + }, { tabProperties: ['item', 'tab'] }).catch(_error => {}); + } + } + mLastDragEnteredTarget = target; +} + +function onTSTAPIDragExit(event) { + if (mDragTargetIsClosebox && + !EventUtils.isEventFiredOnClosebox(event)) + return; + const item = EventUtils.getTreeItemFromEvent(event); + if (!item) + return; + let target = item.$TST.element; + if (mDragTargetIsClosebox && EventUtils.isEventFiredOnClosebox(event)) + target = target && item.$TST.element.closeBox; + cancelDelayedTSTAPIDragExitOn(target); + const timeout = setTimeout(() => { + if (target) + mDragExitTimeoutForTarget.delete(target); + if (!target || !target.parentNode) // already removed + return; + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_TAB_DRAGEXIT, + item, + tab: item.$TST.tab, // for backward compatibility + window: item.windowId, + windowId: item.windowId + }, { tabProperties: ['item', 'tab'] }).catch(_error => {}); + target = null; + }, 10); + mDragExitTimeoutForTarget.set(target, timeout); +} + +function cancelDelayedTSTAPIDragExitOn(target) { + const timeout = target && mDragExitTimeoutForTarget.get(target); + if (timeout) { + clearTimeout(timeout); + mDragExitTimeoutForTarget.delete(target); + } +} + + +function onMessage(message, _sender, _respond) { + if (!message || + typeof message.type != 'string') + return; + + switch (message.type) { + case Constants.kCOMMAND_BROADCAST_CURRENT_DRAG_DATA: + setDragData(message.dragData || null); + if (!message.dragData) + onFinishDrag(); + break; + } +} + + +TSTAPI.onMessageExternal.addListener((message, _sender) => { + switch (message.type) { + case TSTAPI.kGET_DRAG_DATA: + if (mCurrentDragDataForExternals && + message.id == mCurrentDragDataForExternalsId) + return Promise.resolve(mCurrentDragDataForExternals); + break; + } +}); diff --git a/waterfox/browser/components/sidebar/sidebar/event-utils.js b/waterfox/browser/components/sidebar/sidebar/event-utils.js new file mode 100644 index 000000000000..e7df012aa54f --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/event-utils.js @@ -0,0 +1,325 @@ +/* +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. +*/ +'use strict'; + +import { + log as internalLogger, + countMatched, + configs, + isMacOS, +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; +import * as SidebarItems from './sidebar-items.js'; +import * as Size from './size.js'; + +import { TreeItem } from '/common/TreeItem.js'; + +import { kTAB_CLOSE_BOX_ELEMENT_NAME } from './components/TabCloseBoxElement.js'; +import { kTAB_FAVICON_ELEMENT_NAME } from './components/TabFaviconElement.js'; +import { kTAB_SOUND_BUTTON_ELEMENT_NAME } from './components/TabSoundButtonElement.js'; +import { kTAB_TWISTY_ELEMENT_NAME } from './components/TabTwistyElement.js'; +import { kTREE_ITEM_ELEMENT_NAME } from './components/TreeItemElement.js'; + +// eslint-disable-next-line no-unused-vars +function log(...args) { + internalLogger('sidebar/event-utils', ...args); +} + +let mTargetWindow; + +export function setTargetWindowId(windowId) { + mTargetWindow = windowId; +} + + +export function isMiddleClick(event) { + return event.button == 1; +} + +export function isAccelAction(event) { + return isMiddleClick(event) || (event.button == 0 && isAccelKeyPressed(event)); +} + +export function isAccelKeyPressed(event) { + return isMacOS() ? + (event.metaKey || event.key == 'Meta') : + (event.ctrlKey || event.key == 'Control') ; +} + +export function isCopyAction(event) { + return isAccelKeyPressed(event) || + (event.dataTransfer?.dropEffect == 'copy'); +} + +export function getElementTarget(eventOrTarget) { + const target = eventOrTarget instanceof Node ? + eventOrTarget : + eventOrTarget.target; + if (target.nodeType == Node.TEXT_NODE) + return target.parentNode; + return target instanceof Element ? target : null; +} + +export function getElementOriginalTarget(eventOrTarget) { + const target = eventOrTarget instanceof Node ? + eventOrTarget : + (event => { + try { + if (event.originalTarget && + event.originalTarget.nodeType) + return event.originalTarget; + } + catch(_error) { + // Access to the origianlTarget can be restricted on some cases, + // ex. mousedown in extra contents of the new tab button. Why? + } + return event.explicitOriginalTarget || eventOrTarget.target; + })(eventOrTarget); + if (target.nodeType == Node.TEXT_NODE) + return target.parentNode; + return target instanceof Element ? target : null; +} + +export function isEventFiredOnTwisty(event) { + const tab = getTreeItemFromEvent(event); + if (!tab || !tab.$TST.hasChild) + return false; + + const target = getElementTarget(event); + return target?.closest && !!target.closest(kTAB_TWISTY_ELEMENT_NAME); +} + +export function isEventFiredOnSharingState(event) { + const target = getElementTarget(event); + if (!target?.closest(kTAB_FAVICON_ELEMENT_NAME)) { + return false; + } + const tab = target.closest(kTREE_ITEM_ELEMENT_NAME); + const sharingState = tab?.raw?.sharingState; + return !!(sharingState?.microphone || sharingState?.camera || sharingState?.screen); +} + +export function isEventFiredOnSoundButton(event) { + const target = getElementTarget(event); + return target?.closest && !!target.closest(kTAB_SOUND_BUTTON_ELEMENT_NAME); +} + +export function isEventFiredOnClosebox(event) { + const target = getElementTarget(event); + return target?.closest && !!target.closest(kTAB_CLOSE_BOX_ELEMENT_NAME); +} + +export function isEventFiredOnNewTabButton(event) { + const target = getElementTarget(event); + return target?.closest && !!target.closest(`.${Constants.kNEWTAB_BUTTON}`); +} + +export function isEventFiredOnMenuOrPanel(event) { + const target = getElementTarget(event); + return target?.closest && !!target.closest('ul.menu, ul.panel'); +} + +export function isEventFiredOnAnchor(event) { + const target = getElementTarget(event); + return target?.closest && !!target.closest(`[data-menu-ui]`); +} + +export function isEventFiredOnClickable(event) { + const target = getElementTarget(event); + return target?.closest && !!target.closest(`button, scrollbar, select`); +} + +export function isEventFiredOnTabbarTop(event) { + const target = getElementTarget(event); + return target?.closest && !!target.closest('#tabbar-top'); +} + +export function isEventFiredOnTabbarBottom(event) { + const target = getElementTarget(event); + return target?.closest && !!target.closest('#tabbar-bottom'); +} + + +export function getTreeItemFromEvent(event, options = {}) { + return SidebarItems.getItemFromDOMNode(event.target, options); +} + +function getTabbarFromEvent(event) { + let node = event.target; + if (!node) + return null; + if (!(node instanceof Element)) + node = node.parentNode; + return node?.closest('.tabs'); +} + +export function getTreeItemFromTabbarEvent(event, options = {}) { + if (!configs.shouldDetectClickOnIndentSpaces || + isEventFiredOnClickable(event)) + return null; + return getTreeItemFromCoordinates(event, options); +} + +function getTreeItemFromCoordinates(event, options = {}) { + const item = SidebarItems.getItemFromDOMNode(document.elementFromPoint(event.clientX, event.clientY), options); + if (item) + return item; + + const container = getTabbarFromEvent(event); + if (!container || + container.classList.contains('pinned')) + return null; + + // because item style can be modified, we try to find item from + // left, middle, and right. + const containerRect = container.getBoundingClientRect(); + const trialPoints = [ + Size.getFavIconSize(), + containerRect.width / 2, + containerRect.width - Size.getFavIconSize() + ]; + for (const x of trialPoints) { + const item = SidebarItems.getItemFromDOMNode(document.elementFromPoint(x, event.clientY), options); + if (item) + return item.type == TreeItem.TYPE_TAB && item; // we should find only tabs from their indent space + } + + // document.elementFromPoint cannot find elements being in animation effect, + // so I try to find a item from previous or next item. + const height = Size.getTabHeight(); + for (const x of trialPoints) { + let item = SidebarItems.getItemFromDOMNode(document.elementFromPoint(x, event.clientY - height), options); + item = SidebarItems.getItemFromDOMNode(item?.$TST.element.nextSibling, options); + if (item) + return item.type == TreeItem.TYPE_TAB && item; // we should find only tabs from their indent space + } + for (const x of trialPoints) { + let item = SidebarItems.getItemFromDOMNode(document.elementFromPoint(x, event.clientY + height), options); + item = SidebarItems.getItemFromDOMNode(item?.$TST.element.previousSibling, options); + if (item) + return item.type == TreeItem.TYPE_TAB && item; // we should find only tabs from their indent space + } + + return null; +} + + +const lastMousedown = new Map(); + +export function getLastMousedown(button) { + return lastMousedown.get(button); +} + +export function setLastMousedown(button, details) { + lastMousedown.set(button, details); +} + +export function cancelHandleMousedown(button = null) { + if (!button && button !== 0) { + return countMatched(Array.from(lastMousedown.keys()), + button => cancelHandleMousedown(button)) > 0; + } + + const lastMousedownForButton = lastMousedown.get(button); + if (lastMousedownForButton) { + clearTimeout(lastMousedownForButton.timeout); + lastMousedown.delete(button); + return true; + } + return false; +} + + +export function getEventDetail(event) { + return { + targetType: getEventTargetType(event), + window: mTargetWindow, + windowId: mTargetWindow, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + metaKey: event.metaKey, + }; +} + +export function getTreeItemEventDetail(event, tab) { + return { + ...getEventDetail(event), + tab: tab?.id, + tabId: tab?.id, + tabType: tab?.$TST?.type || tab?.type, + }; +} + +export function getMouseEventDetail(event, tab) { + return { + ...getTreeItemEventDetail(event, tab), + twisty: isEventFiredOnTwisty(event), + sharingState: isEventFiredOnSharingState(event), + soundButton: isEventFiredOnSoundButton(event), + closebox: isEventFiredOnClosebox(event), + button: event.button, + isMiddleClick: isMiddleClick(event), + isAccelClick: isAccelAction(event), + lastInnerScreenY: window.mozInnerScreenY, + }; +} + +export function getEventTargetType(event) { + const element = event.target.closest ? + event.target : + event.target.parentNode; + if (element && + element.closest('.rich-confirm, #blocking-screen')) + return 'outside'; + + if (getTreeItemFromEvent(event)) + return 'tab'; + + if (isEventFiredOnNewTabButton(event)) + return 'newtabbutton'; + + if (isEventFiredOnMenuOrPanel(event) || + isEventFiredOnAnchor(event)) + return 'selector'; + + if (isEventFiredOnTabbarTop(event)) + return 'tabbar-top'; + if (isEventFiredOnTabbarBottom(event)) + return 'tabbar-bottom'; + + const allRange = document.createRange(); + allRange.selectNodeContents(document.body); + const containerRect = allRange.getBoundingClientRect(); + allRange.detach(); + if (event.clientX < containerRect.left || + event.clientX > containerRect.right || + event.clientY < containerRect.top || + event.clientY > containerRect.bottom) + return 'outside'; + + return 'blank'; +} + + +export function wrapWithErrorHandler(func) { + return (...args) => { + try { + const result = func(...args); + if (result && result instanceof Promise) + return result.catch(e => { + console.log('Fatal async error: ', e); + throw e; + }); + else + return result; + } + catch(e) { + console.log('Fatal error: ', e); + throw e; + } + }; +} diff --git a/waterfox/browser/components/sidebar/sidebar/gap-canceller.js b/waterfox/browser/components/sidebar/sidebar/gap-canceller.js new file mode 100644 index 000000000000..ee1bb397f566 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/gap-canceller.js @@ -0,0 +1,235 @@ +/* +# 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'; + +// workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=727668 + +import { + log as internalLogger, + configs, + isNewTabCommandTab, +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; +import * as TabsStore from '/common/tabs-store.js'; + +import { Tab } from '/common/TreeItem.js'; + +import * as BackgroundConnection from './background-connection.js'; + +function log(...args) { + internalLogger('sidebar/gap-canceller', ...args); +} + +let mWindowId; +const mStyle = document.documentElement.style; +const mDataset = document.documentElement.dataset; + +let mByMouseOperation = false; +let mLastWindowDimension = getWindowDimension(); +let mLastMozInnerScreenY = window.mozInnerScreenY; +let mOffset = 0; + +export function init() { + mWindowId = TabsStore.getCurrentWindowId(); + + browser.tabs.query({ active: true, windowId: mWindowId }).then(async tabs => { + if (tabs.length == 0) + tabs = await browser.tabs.query({ windowId: mWindowId }); + onLocationChange(tabs[0]); + }); + BackgroundConnection.onMessage.addListener(async message => { + switch (message.type) { + case Constants.kCOMMAND_NOTIFY_TAB_ACTIVATING: + const tab = Tab.get(message.tabId); + if (tab) { + onLocationChange(tab, { byMouseOperation: message.byMouseOperation }); + if (!message.byMouseOperation) + updateOffset(); + } + break; + } + }); + browser.tabs.onUpdated.addListener((_tabId, changeInfo, tab) => { + if (tab.active && changeInfo.status == 'complete') + onLocationChange(tab); + }, { windowId: mWindowId, properties: ['status'] }); + + if (shouldWatchVisualGap()) + startWatching(); + + configs.$addObserver(changedKey => { + switch (changedKey) { + case 'suppressGapFromShownOrHiddenToolbarOnFullScreen': + case 'suppressGapFromShownOrHiddenToolbarOnNewTab': + case 'suppressGapFromShownOrHiddenToolbarInterval': + if (shouldWatchVisualGap()) + startWatching(); + else + stopWatching(); + break; + } + }); +} + +function shouldWatchVisualGap() { + return ( + configs.suppressGapFromShownOrHiddenToolbarOnFullScreen || + configs.suppressGapFromShownOrHiddenToolbarOnNewTab + ); +} + +function getWindowDimension() { + return `(${window.screenX},${window.screenY}), ${window.outerWidth}x${window.outerHeight}, innerX=${window.mozInnerScreenX}`; +} + +export function getOffset() { + return mOffset; +} + +function updateOffset() { + const dimension = getWindowDimension(); + + const isNewTab = isNewTabCommandTab({ + title: mDataset.activeTabTitle, + url: mDataset.activeTabUrl, + }); + const isFullScreen = mDataset.ownerWindowState == 'fullscreen' + const shouldSuppressGapOnNewTab = ( + configs.suppressGapFromShownOrHiddenToolbarOnNewTab && + isNewTab && + !isFullScreen + ); + const shouldSuppressGapOnFullScreen = ( + configs.suppressGapFromShownOrHiddenToolbarOnFullScreen && + isFullScreen + ); + const shouldSuppressGap = ( + (shouldSuppressGapOnNewTab || shouldSuppressGapOnFullScreen) && + (mByMouseOperation || !configs.suppressGapFromShownOrHiddenToolbarOnlyOnMouseOperation) + ); + log('updateOffset: ', { + title: mDataset.activeTabTitle, + url: mDataset.activeTabUrl, + isNewTab, + state: mDataset.ownerWindowState, + mByMouseOperation, + dimension, + lastDimension: mLastWindowDimension, + innerScreenY: window.mozInnerScreenY, + lastInnerScreenY: mLastMozInnerScreenY, + windowNotChanged: dimension == mLastWindowDimension, + sidebarMoved: mLastMozInnerScreenY != window.mozInnerScreenY + }); + if (dimension == mLastWindowDimension && + mLastMozInnerScreenY != window.mozInnerScreenY) { + if (shouldSuppressGap) { + mOffset = Math.min(0, mLastMozInnerScreenY - window.mozInnerScreenY); + mStyle.setProperty('--visual-gap-offset', `${mOffset}px`); + const currentState = document.documentElement.classList.contains(Constants.kTABBAR_STATE_HAS_VISUAL_GAP); + const newState = mOffset < 0; + document.documentElement.classList.toggle(Constants.kTABBAR_STATE_HAS_VISUAL_GAP, newState); + log(' => should suppress visual gap: offset = ', mOffset); + if (currentState != newState) { + cancelUpdateOffset(); + if (newState) + startListenMouseEvents() + else + endListenMouseEvents(); + } + cancelUpdateOffset(); + } + else { + mStyle.setProperty('--visual-gap-offset', '0px'); + log(' => should not suppress, but there is a visual gap '); + } + } + else if (!shouldSuppressGap) { + mStyle.setProperty('--visual-gap-offset', '0px'); + log(' => should not suppress, no visual gap '); + } + mLastWindowDimension = dimension; + mLastMozInnerScreenY = window.mozInnerScreenY; + browser.windows.get(mWindowId).then(win => { + mDataset.ownerWindowState = win.state; + }); +} + +function startWatching() { + stopWatching(); + window.addEventListener('resize', onResize); +} + +function stopWatching() { + cancelUpdateOffset(); + window.removeEventListener('resize', onResize); +} + +function onResize() { + cancelUpdateOffset(); + // We need to try checking updateed mozInnerScreenY, because the + // mozInnerScreenY is sometimes not updated yet when a resize event + // is dispatched. + // (ResizeObserver has same problem.) + updateOffset.intervalTimer = window.setInterval( + updateOffset, + configs.suppressGapFromShownOrHiddenToolbarInterval + ); + updateOffset.timeoutTimer = setTimeout(() => { + cancelUpdateOffset(); + }, configs.suppressGapFromShownOrHiddenToolbarTimeout); +} + +function cancelUpdateOffset() { + if (updateOffset.intervalTimer) { + window.clearInterval(updateOffset.intervalTimer); + delete updateOffset.intervalTimer; + mByMouseOperation = false; + } + if (updateOffset.timeoutTimer) { + window.clearTimeout(updateOffset.timeoutTimer); + delete updateOffset.timeoutTimer; + } +} + +function onLocationChange(tab, { byMouseOperation } = {}) { + mDataset.activeTabTitle = tab.title; + mDataset.activeTabUrl = tab.url; + if (byMouseOperation) + mByMouseOperation = true; +} + +function startListenMouseEvents() { + if (!onMouseMove.listening) { + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseout', onMouseMove); + onMouseMove.listening = true; + } +} + +function endListenMouseEvents() { + if (onMouseMove.listening) { + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseout', onMouseMove); + onMouseMove.listening = false; + } +} + +let mClearHoverTopEdgeTimer; + +function onMouseMove(event) { + if (mClearHoverTopEdgeTimer) + clearTimeout(mClearHoverTopEdgeTimer); + const onTopEdge = event.screenY < window.mozInnerScreenY - mOffset; + if (onTopEdge) { + document.documentElement.classList.add(Constants.kTABBAR_STATE_HOVER_ON_TOP_EDGE); + } + else { + mClearHoverTopEdgeTimer = setTimeout(() => { + mClearHoverTopEdgeTimer = null; + document.documentElement.classList.remove(Constants.kTABBAR_STATE_HOVER_ON_TOP_EDGE); + }, configs.cancelGapSuppresserHoverDelay); + } +} diff --git a/waterfox/browser/components/sidebar/sidebar/indent.js b/waterfox/browser/components/sidebar/sidebar/indent.js new file mode 100644 index 000000000000..dd7e691805b9 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/indent.js @@ -0,0 +1,284 @@ +/* +# 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'; + +import { + log as internalLogger, + configs, + shouldApplyAnimation +} from '/common/common.js'; + +import * as Constants from '/common/constants.js'; +import * as TabsStore from '/common/tabs-store.js'; + +import { Tab } from '/common/TreeItem.js'; + +import * as BackgroundConnection from './background-connection.js'; +import * as CollapseExpand from './collapse-expand.js'; + +function log(...args) { + internalLogger('sidebar/indent', ...args); +} + +let mPromisedInitializedResolver; +let mPromisedInitialized = new Promise((resolve, _reject) => { + mPromisedInitializedResolver = resolve; +}); +let mIndentDefinition; +let mLastMaxLevel = -1; +let mLastMaxIndent = -1; +let mTargetWindow; +let mTabBar; + +export function init() { + mTargetWindow = TabsStore.getCurrentWindowId(); + mTabBar = document.querySelector('#tabbar'); + + window.addEventListener('resize', reserveToUpdateIndent); + + mPromisedInitializedResolver(); + mPromisedInitialized = mPromisedInitializedResolver = null; +} + +export function updateRestoredTree(cachedIndent) { + updateVisualMaxTreeLevel(); + update({ + force: true, + cache: cachedIndent + }); +} + +export function update(options = {}) { + if (!options.cache) { + const maxLevel = getMaxTreeLevel(mTargetWindow); + const maxIndent = mTabBar.offsetWidth * (0.33); + if (maxLevel <= mLastMaxLevel && + maxIndent == mLastMaxIndent && + !options.force) + return; + + mLastMaxLevel = maxLevel + 5; + mLastMaxIndent = maxIndent; + } + else { + mLastMaxLevel = options.cache.lastMaxLevel; + mLastMaxIndent = options.cache.lastMaxIndent; + } + + if (!mIndentDefinition) { + mIndentDefinition = document.createElement('style'); + mIndentDefinition.setAttribute('type', 'text/css'); + document.head.appendChild(mIndentDefinition); + } + + if (options.cache && + options.cache.definition) { + mIndentDefinition.textContent = options.cache.definition; + } + else { + const indentToSelectors = {}; + const defaultIndentToSelectors = {}; + const indentUnitDefinitions = []; + for (let i = 0; i <= mLastMaxLevel; i++) { + generateIndentAndSelectorsForMaxLevel(i, indentToSelectors, defaultIndentToSelectors, indentUnitDefinitions); + } + + const definitions = []; + for (const indentSet of [defaultIndentToSelectors, indentToSelectors]) { + const indents = Object.keys(indentSet); + indents.sort((aA, aB) => parseInt(aA) - parseInt(aB)); + for (const indent of indents) { + definitions.push(`${indentSet[indent].join(',\n')} { --tab-indent: ${indent}; }`); + } + } + mIndentDefinition.textContent = indentUnitDefinitions.concat(definitions).join('\n'); + } +} +function generateIndentAndSelectorsForMaxLevel(maxLevel, indentToSelectors, defaultIndentToSelectors, indentUnitDefinitions) { + const indentUnit = calculateIndentUnit(maxLevel); + + let configuredMaxLevel = configs.maxTreeLevel; + if (configuredMaxLevel < 0) + configuredMaxLevel = Number.MAX_SAFE_INTEGER; + + const base = `:root[${Constants.kMAX_TREE_LEVEL}="${maxLevel}"]:not(.initializing) .tab:not(.${Constants.kTAB_STATE_PINNED}):not(.${Constants.kTAB_STATE_COLLAPSED_DONE})[${Constants.kLEVEL}]`; + + // default indent for unhandled (deep) level tabs + const defaultIndent = `${Math.min(maxLevel + 1, configuredMaxLevel) * indentUnit}px`; + if (!defaultIndentToSelectors[defaultIndent]) + defaultIndentToSelectors[defaultIndent] = []; + defaultIndentToSelectors[defaultIndent].push(`${base}:not([${Constants.kLEVEL}="0"])`); + + indentUnitDefinitions.push(`:root[${Constants.kMAX_TREE_LEVEL}="${maxLevel}"]:not(.initializing) { + --indent-size: ${indentUnit}px; + }`); + + for (let level = 1; level <= maxLevel; level++) { + const indent = `${Math.min(level, configuredMaxLevel) * indentUnit}px`; + if (!indentToSelectors[indent]) + indentToSelectors[indent] = []; + indentToSelectors[indent].push(`${base}[${Constants.kLEVEL}="${level}"]`); + } +} +function calculateIndentUnit(maxLevel) { + const minIndent = Math.max(Constants.kDEFAULT_MIN_INDENT, configs.minIndent); + return Math.min(configs.baseIndent, Math.max(Math.floor(mLastMaxIndent / maxLevel), minIndent)); +} + +export function getCacheInfo() { + return { + lastMaxLevel: mLastMaxLevel, + lastMaxIndent: mLastMaxIndent, + definition: mIndentDefinition.textContent + }; +} + + +export function tryUpdateVisualMaxTreeLevel() { + log('tryUpdateVisualMaxTreeLevel'); + if (updateVisualMaxTreeLevel.waiting) { + clearTimeout(updateVisualMaxTreeLevel.waiting); + delete updateVisualMaxTreeLevel.waiting; + } + + tryUpdateVisualMaxTreeLevel.calledCount++; + + const animation = shouldApplyAnimation(); + + // On no-animation mode, we should update max indent level immediately + // as possible as we can without delay, to reduce visual flicking which + // can trigger an epileptic seizure. + // But we also have to reduce needless function calls for better performance. + // This threshold is a safe guard for uncared cases with too many call + // of updateVisualMaxTreeLevel(). + // See also: https://github.com/piroor/treestyletab/issues/3383 + if (tryUpdateVisualMaxTreeLevel.calledCount <= configs.maxAllowedImmediateRefreshCount && + !animation) { + updateVisualMaxTreeLevel(); + if (tryUpdateVisualMaxTreeLevel.waitingToResetCalledCount) + clearTimeout(tryUpdateVisualMaxTreeLevel.waitingToResetCalledCount); + tryUpdateVisualMaxTreeLevel.waitingToResetCalledCount = setTimeout(() => { + delete tryUpdateVisualMaxTreeLevel.waitingToResetCalledCount; + tryUpdateVisualMaxTreeLevel.calledCount = 0; + }, 0); + return; + } + + const delay = animation ? Math.max(0, configs.collapseDuration) * 1.5 : 0; + + updateVisualMaxTreeLevel.waiting = setTimeout(() => { + delete updateVisualMaxTreeLevel.waiting; + tryUpdateVisualMaxTreeLevel.calledCount = 0; + updateVisualMaxTreeLevel(); + }, delay); +} +tryUpdateVisualMaxTreeLevel.calledCount = 0; + +async function updateVisualMaxTreeLevel() { + if (mPromisedInitialized) + await mPromisedInitialized; + + const maxLevel = getMaxTreeLevel(mTargetWindow, { + onlyVisible: configs.indentAutoShrinkOnlyForVisible + }); + log('updateVisualMaxTreeLevel ', { maxLevel }); + document.documentElement.setAttribute(Constants.kMAX_TREE_LEVEL, Math.max(1, maxLevel)); +} + +function getMaxTreeLevel(windowId, options = {}) { + if (typeof options != 'object') + options = {}; + const tabs = options.onlyVisible ? + Tab.getVisibleTabs(windowId, { ordered: false }) : + Tab.getTabs(windowId, { ordered: false }) ; + let maxLevel = Math.max(...tabs.map(tab => parseInt(tab.$TST.attributes[Constants.kLEVEL] || 0))); + if (configs.maxTreeLevel > -1) + maxLevel = Math.min(maxLevel, configs.maxTreeLevel); + return maxLevel; +} + +async function reserveToUpdateIndent() { + if (mPromisedInitialized) + await mPromisedInitialized; + log('reserveToUpdateIndent'); + if (reserveToUpdateIndent.waiting) + clearTimeout(reserveToUpdateIndent.waiting); + const delay = shouldApplyAnimation() ? Math.max(configs.indentDuration, configs.collapseDuration) * 1.5 : 100; + reserveToUpdateIndent.waiting = setTimeout(() => { + delete reserveToUpdateIndent.waiting; + update(); + }, delay); +} + + +const restVisibilityChangedTabIds = new Set(); + +CollapseExpand.onUpdated.addListener((tab, _options) => { + const isFinishBatch = restVisibilityChangedTabIds.has(tab.id); + restVisibilityChangedTabIds.delete(tab.id); + + if ((configs.indentAutoShrink && + configs.indentAutoShrinkOnlyForVisible) || + // On no-animation mode, we should update max indent level immediately + // as possible as we can without delay, to reduce visual flicking which + // can trigger an epileptic seizure. + // But we also have to reduce needless function calls for better performance. + // So we throttle the function call of updateVisualMaxTreeLevel() until + // collapsed state of all tabs notified with "kCOMMAND_NOTIFY_SUBTREE_COLLAPSED_STATE_CHANGED" + // are completely updated. + // See also: https://github.com/piroor/treestyletab/issues/3383 + (isFinishBatch && + restVisibilityChangedTabIds.size == 0)) + tryUpdateVisualMaxTreeLevel(); +}); + +const BUFFER_KEY_PREFIX = 'indent-'; + +BackgroundConnection.onMessage.addListener(async message => { + switch (message.type) { + case Constants.kCOMMAND_NOTIFY_TAB_CREATED: + case Constants.kCOMMAND_NOTIFY_TAB_REMOVING: + log('listen: ', message.type); + tryUpdateVisualMaxTreeLevel(); + break; + + case Constants.kCOMMAND_NOTIFY_TAB_SHOWN: + case Constants.kCOMMAND_NOTIFY_TAB_HIDDEN: + case Constants.kCOMMAND_NOTIFY_CHILDREN_CHANGED: + log('listen: ', message.type); + reserveToUpdateIndent(); + tryUpdateVisualMaxTreeLevel(); + break; + + case Constants.kCOMMAND_NOTIFY_TAB_LEVEL_CHANGED: { + if (BackgroundConnection.handleBufferedMessage(message, `${BUFFER_KEY_PREFIX}${message.tabId}`)) + return; + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + const lastMessage = BackgroundConnection.fetchBufferedMessage(message.type, `${BUFFER_KEY_PREFIX}${message.tabId}`); + log('listen: ', message.type, tab, lastMessage); + if (!tab || + !lastMessage) + return; + if (tab.$TST.getAttribute(Constants.kLEVEL) != lastMessage.level) { + tab.$TST.setAttribute(Constants.kLEVEL, lastMessage.level); + tryUpdateVisualMaxTreeLevel(); + } + reserveToUpdateIndent(); + }; break; + + case Constants.kCOMMAND_NOTIFY_SUBTREE_COLLAPSED_STATE_CHANGED: + for (const id of message.visibilityChangedTabIds) { + restVisibilityChangedTabIds.add(id); + } + break; + + case Constants.kCOMMAND_NOTIFY_TAB_COLLAPSED_STATE_CHANGED: + if (!restVisibilityChangedTabIds.has(message.tabId)) + tryUpdateVisualMaxTreeLevel(); + break; + } +}); diff --git a/waterfox/browser/components/sidebar/sidebar/index-ws.js b/waterfox/browser/components/sidebar/sidebar/index-ws.js new file mode 100644 index 000000000000..55c0a231a6df --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/index-ws.js @@ -0,0 +1,40 @@ +/* +# 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'; + +import './index.js'; + +import * as RetrieveURL from '/common/retrieve-url.js'; +import { Tab } from '/common/TreeItem.js'; + +import * as Sidebar from './sidebar.js'; +import './tab-context-menu.js'; +import './tab-preview-tooltip.js'; +import './tab-preview.js'; + +RetrieveURL.registerFileURLResolver(async file => { + return file && browser.waterfoxBridge.getFileURL({ + lastModified: file.lastModified, + name: file.name, + size: file.size, + type: file.type, + }); +}); + +RetrieveURL.registerSelectionClipboardProvider({ + isAvailable: () => browser.waterfoxBridge.isSelectionClipboardAvailable(), + getTextData: () => browser.waterfoxBridge.getSelectionClipboardContents(), +}); + +// Deactivate tab tooltip for tab hover previews +Tab.onCreated.addListener(tab => { + tab.$TST.registerTooltipText(browser.runtime.id, '', true); +}); +Sidebar.onReady.addListener(() => { + for (const tab of Tab.getAllTabs()) { + tab.$TST.registerTooltipText(browser.runtime.id, '', true); + } +}); diff --git a/waterfox/browser/components/sidebar/sidebar/index.js b/waterfox/browser/components/sidebar/sidebar/index.js new file mode 100644 index 000000000000..d51858546e4f --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/index.js @@ -0,0 +1,47 @@ +/* +# 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'; + +import '/extlib/l10n.js'; + +import { + log, + configs +} from '/common/common.js'; + +import * as TabsStore from '/common/tabs-store.js'; + +import MetricsData from '/common/MetricsData.js'; +import { Tab } from '/common/TreeItem.js'; + +import * as BackgroundConnection from './background-connection.js'; +import * as Sidebar from './sidebar.js'; +import './collapse-expand.js'; +import './mouse-event-listener.js'; +import './tab-context-menu.js'; +import './tab-preview-tooltip.js'; +import './tst-api-frontend.js'; + +log.context = 'Sidebar-?'; + +MetricsData.add('Loaded'); + +window.addEventListener('load', Sidebar.init, { once: true }); + +window.dumpMetricsData = () => { + return MetricsData.toString(); +}; +window.dumpLogs = () => { + return log.logs.join('\n'); +}; + +// for old debugging method +window.log = log; +window.gMetricsData = MetricsData; +window.Tab = Tab; +window.TabsStore = TabsStore; +window.BackgroundConnection = BackgroundConnection; +window.configs = configs; diff --git a/waterfox/browser/components/sidebar/sidebar/mouse-event-listener.js b/waterfox/browser/components/sidebar/sidebar/mouse-event-listener.js new file mode 100644 index 000000000000..286e28235acc --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/mouse-event-listener.js @@ -0,0 +1,1227 @@ +/* ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is the Tree Style Tab. + * + * The Initial Developer of the Original Code is YUKI "Piro" Hiroshi. + * Portions created by the Initial Developer are Copyright (C) 2011-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): YUKI "Piro" Hiroshi + * wanabe + * Tetsuharu OHZEKI + * Xidorn Quan (Firefox 40+ support) + * lv7777 (https://github.com/lv7777) + * + * ***** END LICENSE BLOCK ******/ +'use strict'; + +import MenuUI from '/extlib/MenuUI.js'; + +import { + log as internalLogger, + wait, + dumpTab, + countMatched, + configs, + shouldApplyAnimation, + mapAndFilter, + isMacOS, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from '/common/constants.js'; +import * as RetrieveURL from '/common/retrieve-url.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TreeBehavior from '/common/tree-behavior.js'; +import * as TSTAPI from '/common/tst-api.js'; + +import MetricsData from '/common/MetricsData.js'; +import { Tab, TabGroup, TreeItem } from '/common/TreeItem.js'; + +import * as BackgroundConnection from './background-connection.js'; +import * as EventUtils from './event-utils.js'; +import * as DragAndDrop from './drag-and-drop.js'; +import * as Scroll from './scroll.js'; +import * as Sidebar from './sidebar.js'; +import * as TabContextMenu from './tab-context-menu.js'; +import * as TSTAPIFrontend from './tst-api-frontend.js'; + +import { kTAB_CLOSE_BOX_ELEMENT_NAME } from './components/TabCloseBoxElement.js'; +import { kTAB_FAVICON_ELEMENT_NAME } from './components/TabFaviconElement.js'; +import { kTAB_TWISTY_ELEMENT_NAME } from './components/TabTwistyElement.js'; + +function log(...args) { + internalLogger('sidebar/mouse-event-listener', ...args); +} + +let mTargetWindow; + +const mTabBar = document.querySelector('#tabbar'); +const mContextualIdentitySelector = document.getElementById(Constants.kCONTEXTUAL_IDENTITY_SELECTOR); +const mNewTabActionSelector = document.getElementById(Constants.kNEWTAB_ACTION_SELECTOR); +const mRootClasses = document.documentElement.classList; + +let mHasMouseOverListeners = false; + +Sidebar.onInit.addListener(() => { + mTargetWindow = TabsStore.getCurrentWindowId(); +}); + +Sidebar.onBuilt.addListener(async () => { + document.addEventListener('mousedown', onMouseDown); + document.addEventListener('mouseup', onMouseUp); + document.addEventListener('click', onClick); + document.addEventListener('auxclick', onAuxClick); + document.addEventListener('dragstart', onDragStart); + mTabBar.addEventListener('dblclick', onDblClick); + mTabBar.addEventListener('mouseover', onMouseOver); + + MetricsData.add('mouse-event-listener: Sidebar.onBuilt: apply configs'); + + browser.runtime.onMessage.addListener(onMessage); + BackgroundConnection.onMessage.addListener(onBackgroundMessage); + + if (!mRootClasses.contains('incognito')) + mContextualIdentitySelector.ui = new MenuUI({ + root: mContextualIdentitySelector, + appearance: 'panel', + onCommand: onContextualIdentitySelect, + animationDuration: shouldApplyAnimation() ? configs.collapseDuration : 0.001 + }); + + mNewTabActionSelector.ui = new MenuUI({ + root: mNewTabActionSelector, + appearance: 'panel', + onCommand: onNewTabActionSelect, + animationDuration: shouldApplyAnimation() ? configs.collapseDuration : 0.001 + }); +}); + +Sidebar.onReady.addListener(() => { + updateSpecialEventListenersForAPIListeners(); +}); + +Sidebar.onLayoutUpdated.addListener(() => { + updateSpecialEventListenersForAPIListeners(); +}); + +TSTAPI.onRegistered.addListener(() => { + updateSpecialEventListenersForAPIListeners(); +}); + +TSTAPI.onUnregistered.addListener(() => { + updateSpecialEventListenersForAPIListeners(); +}); + +configs.$addObserver(changedKey => { + switch (changedKey) { + case 'shiftTabsForScrollbarDistance': + case 'shiftTabsForScrollbarOnlyOnHover': + updateSpecialEventListenersForAPIListeners(); + break; + } +}); + +function updateSpecialEventListenersForAPIListeners() { + const shouldListenMouseMove = ( + TSTAPI.hasListenerForMessageType(TSTAPI.kNOTIFY_TAB_MOUSEMOVE) || + (configs.shiftTabsForScrollbarOnlyOnHover && + mTabBar.classList.contains(Constants.kTABBAR_STATE_SCROLLBAR_AUTOHIDE)) + ); + if (shouldListenMouseMove != onMouseMove.listening) { + if (!onMouseMove.listening) { + window.addEventListener('mousemove', onMouseMove, { capture: true, passive: true }); + onMouseMove.listening = true; + } + else { + window.removeEventListener('mousemove', onMouseMove, { capture: true, passive: true }); + onMouseMove.listening = false; + } + } + + const shouldListenMouseOut = TSTAPI.hasListenerForMessageType(TSTAPI.kNOTIFY_TAB_MOUSEOUT); + if (shouldListenMouseOut != onMouseOut.listening) { + if (!onMouseOut.listening) { + window.addEventListener('mouseout', onMouseOut, { capture: true, passive: true }); + onMouseOut.listening = true; + } + else { + window.removeEventListener('mouseout', onMouseOut, { capture: true, passive: true }); + onMouseOut.listening = false; + } + } + + mHasMouseOverListeners = shouldListenMouseOut || TSTAPI.hasListenerForMessageType(TSTAPI.kNOTIFY_TAB_MOUSEOVER); +} + + +/* handlers for DOM events */ + +const mCloseBoxSizeBox = document.querySelector(`#dummy-tab ${kTAB_CLOSE_BOX_ELEMENT_NAME}`); +const mFaviconSizeBox = document.querySelector(`#dummy-tab ${kTAB_FAVICON_ELEMENT_NAME}`); +const mTwistySizeBox = document.querySelector(`#dummy-tab ${kTAB_TWISTY_ELEMENT_NAME}`); +const mDistanceBox = document.querySelector('#dummy-shift-tabs-for-scrollbar-distance-box'); + +function onMouseMove(event) { + const tab = EventUtils.getTreeItemFromEvent(event); + if (mTabBar.classList.contains(Constants.kTABBAR_STATE_SCROLLBAR_AUTOHIDE)) { + const onTabBar = mTabBar.contains(event.target); + const tabbarRect = mTabBar.getBoundingClientRect(); + const twistyRect = mTwistySizeBox.getBoundingClientRect(); + const faviconRect = mFaviconSizeBox.getBoundingClientRect(); + const closeRect = mCloseBoxSizeBox.getBoundingClientRect(); + const placeholderSizeRect = mDistanceBox.getBoundingClientRect(); + const isRightSide = mRootClasses.contains('right'); + const leftAreaSize = onTabBar &&( + isRightSide ? closeRect.width : + Math.max(twistyRect.right, faviconRect.right) - Math.min(twistyRect.left, faviconRect.left) + ) + placeholderSizeRect.width; + const rightAreaSize = onTabBar &&( + !isRightSide ? closeRect.width : + Math.max(twistyRect.right, faviconRect.right) - Math.min(twistyRect.left, faviconRect.left) + ) + placeholderSizeRect.width; + mRootClasses.toggle('on-scrollbar-area', ( + onTabBar && + isRightSide ? event.clientX >= tabbarRect.right - rightAreaSize : + event.clientX <= tabbarRect.left + leftAreaSize + )); + } + + if (TSTAPI.hasListenerForMessageType(TSTAPI.kNOTIFY_TAB_MOUSEMOVE) && + tab) { + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_TAB_MOUSEMOVE, + tab, + window: mTargetWindow, + windowId: mTargetWindow, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + metaKey: event.metaKey, + dragging: DragAndDrop.isCapturingForDragging() + }, { tabProperties: ['tab'] }).catch(_error => {}); + } +} +onMouseMove = EventUtils.wrapWithErrorHandler(onMouseMove); + +let mLastWarmUpTab = -1; + +function onMouseOver(event) { + const tab = EventUtils.getTreeItemFromEvent(event); + + if (tab?.$TST.tab && + mLastWarmUpTab != tab.id && + typeof browser.tabs.warmup == 'function') { + browser.tabs.warmup(tab.id); + mLastWarmUpTab = tab.id; + } + + if (!mHasMouseOverListeners) + return; + + // We enter the tab or one of its children, but not from any of the tabs + // (other) children, so we are now starting to hover this tab (relatedTarget + // contains the target of the mouseout event or null if there is none). This + // also includes the case where we enter the tab directly without going + // through another tab or the sidebar, which causes relatedTarget to be null + const enterTabFromAncestor = tab && !tab.$TST.element.contains(event.relatedTarget); + + if (enterTabFromAncestor) { + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_TAB_MOUSEOVER, + tab, + window: mTargetWindow, + windowId: mTargetWindow, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + metaKey: event.metaKey, + dragging: DragAndDrop.isCapturingForDragging() + }, { tabProperties: ['tab'] }).catch(_error => {}); + } +} +onMouseOver = EventUtils.wrapWithErrorHandler(onMouseOver); + +function onMouseOut(event) { + const tab = EventUtils.getTreeItemFromEvent(event); + + // We leave the tab or any of its children, but not for one of the tabs + // (other) children, so we are no longer hovering this tab (relatedTarget + // contains the target of the mouseover event or null if there is none). This + // also includes the case where we leave the tab directly without going + // through another tab or the sidebar, which causes relatedTarget to be null + const leaveTabToAncestor = tab && !tab.$TST.element.contains(event.relatedTarget); + + if (leaveTabToAncestor) { + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_TAB_MOUSEOUT, + tab, + window: mTargetWindow, + windowId: mTargetWindow, + ctrlKey: event.ctrlKey, + shiftKey: event.shiftKey, + altKey: event.altKey, + metaKey: event.metaKey, + dragging: DragAndDrop.isCapturingForDragging() + }, { tabProperties: ['tab'] }).catch(_error => {}); + } +} +onMouseOut = EventUtils.wrapWithErrorHandler(onMouseOut); + +let mLastDragStartTimestamp = -1; + +function onMouseDown(event) { + EventUtils.cancelHandleMousedown(event.button); + TabContextMenu.close(); + DragAndDrop.clearDropPosition(); + DragAndDrop.clearDraggingState(); + + if (EventUtils.isEventFiredOnAnchor(event) && + !EventUtils.isAccelAction(event) && + event.button != 2) { + log('onMouseDown: canceled / mouse down on a selector anchor'); + event.stopPropagation(); + event.preventDefault(); + const selector = document.getElementById(EventUtils.getElementTarget(event).closest('[data-menu-ui]').dataset.menuUi); + selector.ui.open({ + anchor: event.target + }); + return; + } + + const target = event.target; + const tab = EventUtils.getTreeItemFromEvent(event) || EventUtils.getTreeItemFromTabbarEvent(event); + log('onMouseDown: found target tab: ', tab, event); + + const extraContentsInfo = TSTAPIFrontend.getOriginalExtraContentsTarget(event); + const mousedownDetail = EventUtils.getMouseEventDetail(event, tab); + mousedownDetail.$extraContentsInfo = extraContentsInfo; + log('onMouseDown ', mousedownDetail); + + if (mousedownDetail.targetType == 'selector') + return; + + if (mousedownDetail.isMiddleClick) { + log('onMouseDown: canceled / middle click'); + event.stopPropagation(); + event.preventDefault(); + } + + const mousedown = { + detail: mousedownDetail, + tab, + promisedMousedownNotified: Promise.resolve(), + timestamp: Date.now(), + }; + + const apiEventType = (tab && mousedownDetail.targetType == 'tab') ? + TSTAPI.kNOTIFY_TAB_MOUSEDOWN : + mousedownDetail.targetType == 'newtabbutton' ? + TSTAPI.kNOTIFY_NEW_TAB_BUTTON_MOUSEDOWN : + TSTAPI.kNOTIFY_TABBAR_MOUSEDOWN; + + mousedown.promisedMousedownNotified = Promise.all([ + browser.runtime.sendMessage({ + type: apiEventType, + button: mousedownDetail.button, + altKey: mousedownDetail.altKey, + ctrlKey: mousedownDetail.ctrlKey, + metaKey: mousedownDetail.metaKey, + shiftKey: mousedownDetail.shiftKey, + tab: tab?.$TST?.export(true) || tab, + }).catch(ApiTabs.createErrorHandler()), + (async () => { + log('Sending message to mousedown listeners ', { extraContentsInfo }); + const allowed = await TSTAPIFrontend.tryMouseOperationAllowedWithExtraContents( + TSTAPI.kNOTIFY_EXTRA_CONTENTS_MOUSEDOWN, + apiEventType, + mousedown, + extraContentsInfo + ); + if (!allowed) { + log(' => canceled'); + return true; + } + + log(' => allowed'); + return false; + })() + ]).then(results => results[1]); + + // Firefox switches tab focus on mousedown, and keeps + // tab multiselection for draging of them together. + // We simulate the behavior here. + mousedown.promisedMousedownNotified.then(canceled => { + if (canceled) + EventUtils.cancelHandleMousedown(event.button); + + if (!EventUtils.getLastMousedown(event.button) || + mousedown.expired || + canceled) + return; + + const onRegularArea = ( + !mousedown.detail.twisty && + !mousedown.detail.soundButton && + !mousedown.detail.closebox + ); + const wasMultiselectionAction = ( + mousedown.detail.ctrlKey || + mousedown.detail.shiftKey + ); + if (mousedown.detail.button == 0 && + onRegularArea && + !wasMultiselectionAction && + tab?.$TST?.type == TreeItem.TYPE_TAB) { + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_ACTIVATE_TAB, + tabId: tab.id, + byMouseOperation: true, + keepMultiselection: true + }); + if (tab.active || tab.$TST.states.has(Constants.kTAB_STATE_BUNDLED_ACTIVE)) // sticky active tab + Scroll.scrollToItem(tab); + } + }); + + EventUtils.setLastMousedown(event.button, mousedown); + mousedown.timeout = setTimeout(async () => { + if (!EventUtils.getLastMousedown(event.button)) + return; + + if (event.button == 0 && + mousedownDetail.targetType == 'newtabbutton' && + configs.longPressOnNewTabButton && + mLastDragStartTimestamp < mousedown.timestamp) { + mousedown.expired = true; + const selector = document.getElementById(configs.longPressOnNewTabButton); + if (selector) { + const anchor = target.parentNode.querySelector(`[data-menu-ui="${selector.id}"]`); + const anchorVisible = anchor && window.getComputedStyle(anchor, null).display != 'none'; + selector.ui.open({ + anchor: anchorVisible && anchor || target + }); + } + return; + } + + if (TSTAPI.getListenersForMessageType(TSTAPI.kNOTIFY_TAB_DRAGREADY).length == 0) + return; + + if (event.button == 0 && + tab) { + log('onMouseDown expired'); + mousedown.expired = true; + } + }, configs.longPressDuration); +} +onMouseDown = EventUtils.wrapWithErrorHandler(onMouseDown); + +let mLastMouseUpX = -1; +let mLastMouseUpY = -1; +let mLastMouseUpOnTab = -1; + +async function onMouseUp(event) { + const unsafeTab = EventUtils.getTreeItemFromEvent(event, { force: true }) || EventUtils.getTreeItemFromTabbarEvent(event, { force: true }); + const tab = EventUtils.getTreeItemFromEvent(event) || EventUtils.getTreeItemFromTabbarEvent(event); + log('onMouseUp: ', unsafeTab, { living: !!tab }); + + DragAndDrop.endMultiDrag(unsafeTab, event); + + if (EventUtils.isEventFiredOnMenuOrPanel(event) || + EventUtils.isEventFiredOnAnchor(event)) { + log(' => on menu or anchor'); + return; + } + + const lastMousedown = EventUtils.getLastMousedown(event.button); + EventUtils.cancelHandleMousedown(event.button); + const extraContentsInfo = lastMousedown?.detail?.$extraContentsInfo; + if (!lastMousedown) { + log(' => no lastMousedown'); + return; + } + if (lastMousedown.detail.targetType == 'outside') { + log(' => out of contents'); + return; + } + + if (tab) { + const notifiedTab = lastMousedown.detail?.targetType == 'tab' ? tab : null; + const mouseupInfo = { + ...lastMousedown, + detail: EventUtils.getMouseEventDetail(event, tab), + tab: notifiedTab, + }; + + const mouseupAllowed = await TSTAPIFrontend.tryMouseOperationAllowedWithExtraContents( + TSTAPI.kNOTIFY_EXTRA_CONTENTS_MOUSEUP, + notifiedTab ? TSTAPI.kNOTIFY_TAB_MOUSEUP : TSTAPI.kNOTIFY_TABBAR_MOUSEUP, + mouseupInfo, + extraContentsInfo + ); + if (!mouseupAllowed) { + log(' => not allowed (mouseup)'); + return true; + } + + const clickAllowed = await TSTAPIFrontend.tryMouseOperationAllowedWithExtraContents( + TSTAPI.kNOTIFY_EXTRA_CONTENTS_CLICKED, + notifiedTab ? TSTAPI.kNOTIFY_TAB_CLICKED : TSTAPI.kNOTIFY_TABBAR_CLICKED, + mouseupInfo, + extraContentsInfo + ); + if (!clickAllowed) { + log(' => not allowed (clicked'); + return true; + } + } + + let promisedCanceled = null; + if (lastMousedown.tab && lastMousedown.detail.targetType == 'tab') + promisedCanceled = lastMousedown.promisedMousedownNotified; + + const lastMousedownTab = lastMousedown.detail.tabType == TreeItem.TYPE_GROUP ? + TabGroup.get(lastMousedown.detail.tabId) : + lastMousedown.detail.tabType == TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER ? + TabGroup.get(lastMousedown.detail.tabId).$TST.collapsedMembersCounterItem : + Tab.get(lastMousedown.detail.tabId); + if (lastMousedown.expired || + lastMousedown.detail.targetType != EventUtils.getEventTargetType(event) || // when the cursor was moved before mouseup + (tab && tab != lastMousedownTab)) { // when the tab was already removed + log(' => expired, different type, or different tab ', { + expired: lastMousedown.expired, + targetType: lastMousedown.detail.targetType, + targetTypeFromEvent: EventUtils.getEventTargetType(event), + }); + return; + } + + if (promisedCanceled && await promisedCanceled) { + log('onMouseUp: canceled / by other addons'); + return; + } + + // not canceled, then fallback to default behavior + return handleDefaultMouseUp({ lastMousedown, tab, event }); +} +onMouseUp = EventUtils.wrapWithErrorHandler(onMouseUp); + +let mLastMouseupOnClosebox = false; +async function handleDefaultMouseUp({ lastMousedown, tab, event }) { + log(`handleDefaultMouseUp on ${tab?.id} `, lastMousedown.detail); + + if (tab && + lastMousedown.detail.button != 2 && + await handleDefaultMouseUpOnTab({ lastMousedown, tab, event })) { + log(`onMouseUp: click on the tab ${tab?.id}, handled by default handler`); + return; + } + + if (tab) { + mLastMouseUpX = event.clientX; + mLastMouseUpY = event.clientY; + mLastMouseUpOnTab = Date.now(); + } + + // following codes are for handlig of click event on the tab bar itself. + const actionForNewTabCommand = lastMousedown.detail.isMiddleClick ? + configs.autoAttachOnNewTabButtonMiddleClick : + lastMousedown.detail.isAccelClick ? + configs.autoAttachOnNewTabButtonAccelClick : + configs.autoAttachOnNewTabCommand; + if (EventUtils.isEventFiredOnNewTabButton(event)) { + log('onMouseUp: click on the new tab button'); + if (lastMousedown.detail.button != 2) { + log('onMouseUp: not a context menu request'); + const mouseupInfo = { + ...lastMousedown, + detail: EventUtils.getMouseEventDetail(event), + tab: null, + }; + + const mouseUpAllowed = await TSTAPIFrontend.tryMouseOperationAllowedWithExtraContents( + TSTAPI.kNOTIFY_EXTRA_CONTENTS_MOUSEUP, + TSTAPI.kNOTIFY_NEW_TAB_BUTTON_MOUSEUP, + mouseupInfo, + lastMousedown.detail.$extraContentsInfo + ); + if (!mouseUpAllowed) + return; + + const clickAllowed = await TSTAPIFrontend.tryMouseOperationAllowedWithExtraContents( + TSTAPI.kNOTIFY_EXTRA_CONTENTS_CLICKED, + TSTAPI.kNOTIFY_NEW_TAB_BUTTON_CLICKED, + mouseupInfo, + lastMousedown.detail.$extraContentsInfo + ); + if (!clickAllowed) + return; + + // Simulation of Firefox's built-in behavior. + // See also: https://github.com/piroor/treestyletab/issues/2593 + if (event.shiftKey && !lastMousedown.detail.isAccelClick) { + browser.windows.create({}); + } + else { + const activeTab = Tab.getActiveTab(mTargetWindow); + const cookieStoreId = (actionForNewTabCommand == Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING_WITH_INHERITED_CONTAINER) ? activeTab.cookieStoreId : null + const urls = lastMousedown.detail.isMiddleClick && configs.middleClickPasteURLOnNewTabButton ? + (await RetrieveURL.fromClipboard({ selection: true })) : + []; + log('urls: ', urls); + handleNewTabAction(event, { + action: actionForNewTabCommand, + cookieStoreId, + url: urls.length > 0 ? urls[0] : null, + }); + } + } + return; + } + + const wasMouseupOnClosebox = mLastMouseupOnClosebox; + mLastMouseupOnClosebox = !!lastMousedown.detail.closebox; + + // Multiple middle clicks to close tabs can be detected as a middle click on the tab bar. + // We should ignore if the cursor is not moved and the closing tab is still in animation. + // See also: https://github.com/piroor/treestyletab/issues/1968 + if (shouldApplyAnimation() && + (lastMousedown.detail.isMiddleClick || + (lastMousedown.detail.button == 0 && + !lastMousedown.detail.isAccelClick && + wasMouseupOnClosebox)) && + Date.now() - mLastMouseUpOnTab <= configs.collapseDuration && + Math.abs(mLastMouseUpX - event.clientX) < configs.acceptableFlickerToIgnoreClickOnTabAndTabbar / 2 && + Math.abs(mLastMouseUpY - event.clientY) < configs.acceptableFlickerToIgnoreClickOnTabAndTabbar / 2) { + log('onMouseUp: ignore multi-clicking while closing tab animation'); + return; + } + + const onTabbarTop = EventUtils.isEventFiredOnTabbarTop(event); + const onTabbarBottom = EventUtils.isEventFiredOnTabbarBottom(event); + + log('onMouseUp: notify as a blank area click to other addons'); + if (onTabbarTop || onTabbarBottom) { + log('onMouseUp: on tab bar top or bottom'); + const allowed = await TSTAPIFrontend.tryMouseOperationAllowedWithExtraContents( + TSTAPI.kNOTIFY_EXTRA_CONTENTS_MOUSEUP, + TSTAPI.kNOTIFY_TABBAR_MOUSEUP, + { + ...lastMousedown, + detail: EventUtils.getMouseEventDetail(event), + tab: null, + }, + lastMousedown.detail.$extraContentsInfo + ); + if (!allowed) { + log('onMouseUp: canceled'); + return; + } + } + else { + log('onMouseUp: on somewhere, tab = ', !!lastMousedown.tab); + const mouseUpAllowed = await TSTAPI.tryOperationAllowed( + TSTAPI.kNOTIFY_TABBAR_MOUSEUP, + { + ...lastMousedown.detail, + window: mTargetWindow, + windowId: mTargetWindow, + tab: lastMousedown.tab, + $extraContentsInfo: null + }, + { tabProperties: ['tab'] } + ); + if (!mouseUpAllowed) { + log('onMouseUp: canceled'); + return; + } + } + + if (onTabbarTop || onTabbarBottom) { + log('onMouseUp: extra contents on tab bar top or bottom'); + const allowed = await TSTAPIFrontend.tryMouseOperationAllowedWithExtraContents( + TSTAPI.kNOTIFY_EXTRA_CONTENTS_CLICKED, + TSTAPI.kNOTIFY_TABBAR_CLICKED, + { + ...lastMousedown, + detail: EventUtils.getMouseEventDetail(event), + tab: null, + }, + lastMousedown.detail.$extraContentsInfo + ); + if (!allowed) { + log('onMouseUp: canceled'); + return; + } + } + else { + log('onMouseUp: on somewhere, tab = ', !!lastMousedown.tab); + const clickAllowed = await TSTAPI.tryOperationAllowed( + TSTAPI.kNOTIFY_TABBAR_CLICKED, + { + ...lastMousedown.detail, + window: mTargetWindow, + windowId: mTargetWindow, + tab: lastMousedown.tab, + $extraContentsInfo: null + }, + { tabProperties: ['tab'] } + ); + if (!clickAllowed) { + log('onMouseUp: canceled'); + return; + } + } + + if (lastMousedown.detail.isMiddleClick) { // Ctrl-click does nothing on Firefox's tab bar! + log('onMouseUp: default action for middle click on the blank area'); + handleNewTabAction(event, { + action: configs.autoAttachOnNewTabCommand + }); + } +} +handleDefaultMouseUp = EventUtils.wrapWithErrorHandler(handleDefaultMouseUp); + +async function handleDefaultMouseUpOnTab({ lastMousedown, tab, event } = {}) { + log(`Ready to handle click action on the tab ${tab.id}`); + + const onRegularArea = ( + !lastMousedown.detail.twisty && + !lastMousedown.detail.soundButton && + !lastMousedown.detail.closebox + ); + const wasMultiselectionAction = updateMultiselectionByTabClick(tab, lastMousedown.detail); + log(' => ', { onRegularArea, wasMultiselectionAction }); + + // Firefox clears tab multiselection after the mouseup, so + // we simulate the behavior. + if (lastMousedown.detail.button == 0 && + onRegularArea && + !wasMultiselectionAction) { + switch (tab.$TST.type) { + case TreeItem.TYPE_TAB: + log(' => activate'); + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_ACTIVATE_TAB, + tabId: tab.id, + byMouseOperation: true, + keepMultiselection: false // tab.highlighted + }); + break; + + case TreeItem.TYPE_GROUP: + log(' => toggle group collapsed'); + await browser.tabGroups.update(tab.id, { collapsed: !tab.collapsed }); + break; + + case TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER: + log(' => toggle owner group collapsed'); + await browser.tabGroups.update(tab.id, { collapsed: !tab.group.collapsed }); + break; + } + } + + if (lastMousedown.detail.isMiddleClick) { // Ctrl-click doesn't close tab on Firefox's tab bar! + log(`onMouseUp: middle click on the tab ${tab.id}: targetType = `, lastMousedown.detail.targetType); + if (lastMousedown.detail.targetType != 'tab' || // ignore middle click on blank area + tab.type == TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER) + return false; + const tabs = TreeBehavior.getClosingTabsFromParent(tab, { + byInternalOperation: true + }); + log('tabs: ', tabs); + const sanitizedTabsToClose = mapAndFilter(tabs, tab => tab.type == TreeItem.TYPE_GROUP ? undefined : tab.$TST.sanitized); + log('sanitizedTabsToClose: ', sanitizedTabsToClose); + (tab.type == TreeItem.TYPE_GROUP ? Promise.resolve(true) : // on Firefox 139, middle click on a group closes it with no warning! + Sidebar.confirmToCloseTabs(sanitizedTabsToClose)) + .then(async confirmed => { + if (!confirmed) + return; + const tabIds = sanitizedTabsToClose.map(tab => tab.id); + await Scroll.tryLockPosition(tabIds, Scroll.LOCK_REASON_REMOVE); + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_REMOVE_TABS_BY_MOUSE_OPERATION, + tabIds + }); + }); + } + else if (wasMultiselectionAction) { + // On Firefox's native tabs, Ctrl-Click or Shift-Click always + // ignore actions on closeboxes and sound playing icons. + // Thus we should simulate the behavior. + return true; + } + else if (lastMousedown.detail.twisty && + EventUtils.isEventFiredOnTwisty(event)) { + log(`clicked on twisty of the tab ${tab.id}`); + if (tab.$TST.hasChild) { + if (!tab.$TST.subtreeCollapsed) // going to collapse + await Scroll.tryLockPosition( + tab.$TST.descendants.filter(tab => !tab.$TST.collapsed).map(tab => tab.id), + Scroll.LOCK_REASON_COLLAPSE + ); + else // going to expand + Scroll.tryUnlockPosition(tab.$TST.descendants.map(tab => tab.id)); + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_SET_SUBTREE_COLLAPSED_STATE, + tabId: tab.id, + collapsed: !tab.$TST.subtreeCollapsed, + manualOperation: true, + stack: configs.debug && new Error().stack + }); + } + } + else if (lastMousedown.detail.soundButton && + EventUtils.isEventFiredOnSoundButton(event)) { + log(`clicked on sound button of the tab ${tab.id}`); + if (tab.$TST.states.has(Constants.kTAB_STATE_AUTOPLAY_BLOCKED) || + tab.$TST.states.has(Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER)) { + // Note: there is no built-in handler for this command. + // We need to provide something extra module to handle + // this command with experiments API. + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_UNBLOCK_AUTOPLAY_FROM_SOUND_BUTTON, + tabId: tab.id, + }); + } + else { + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_TOGGLE_MUTED_FROM_SOUND_BUTTON, + tabId: tab.id, + }); + } + } + else if (lastMousedown.detail.closebox && + EventUtils.isEventFiredOnClosebox(event)) { + log(`clicked on closebox of the tab ${tab.id}`); + //if (!warnAboutClosingTabSubtreeOf(tab)) { + // event.stopPropagation(); + // event.preventDefault(); + // return; + //} + const multiselected = tab.$TST.multiselected; + const tabsToBeClosed = multiselected ? + Tab.getSelectedTabs(tab.windowId) : + TreeBehavior.getClosingTabsFromParent(tab, { + byInternalOperation: true + }) ; + Sidebar.confirmToCloseTabs(tabsToBeClosed.map(tab => tab.$TST.sanitized), { + configKey: 'warnOnCloseTabsByClosebox' + }) + .then(async confirmed => { + if (!confirmed) + return; + const tabIds = tabsToBeClosed.map(tab => tab.id); + await Scroll.tryLockPosition(tabIds, Scroll.LOCK_REASON_REMOVE); + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_REMOVE_TABS_BY_MOUSE_OPERATION, + tabIds + }); + }); + } + + return true; +} +handleDefaultMouseUpOnTab = EventUtils.wrapWithErrorHandler(handleDefaultMouseUpOnTab); + +let mLastClickedTab = null; +let mIsInSelectionSession = false; + +function updateMultiselectionByTabClick(tab, event) { + const ctrlKeyPressed = event.ctrlKey || (event.metaKey && isMacOS()); + const activeTab = Tab.getActiveTab(tab.windowId); + const highlightedTabIds = new Set(Tab.getHighlightedTabs(tab.windowId).map(tab => tab.id)); + log(`updateMultiselectionByTabClick on ${tab.id} `, { ctrlKeyPressed, activeTab, highlightedTabIds, mIsInSelectionSession }); + if (event.shiftKey) { + // select the clicked tab and tabs between last activated tab + const lastClickedTab = mLastClickedTab || activeTab; + const betweenTabs = Tab.getTabsBetween(lastClickedTab, tab); + const targetTabs = new Set([lastClickedTab].concat(betweenTabs)); + targetTabs.add(tab); + + log(' => ', { lastClickedTab, betweenTabs, targetTabs }); + + try { + if (!ctrlKeyPressed) { + const alreadySelectedTabs = Tab.getHighlightedTabs(tab.windowId, { iterator: true }); + log(`clear old selection by shift-click on ${tab.id}`); + for (const alreadySelectedTab of alreadySelectedTabs) { + if (!targetTabs.has(alreadySelectedTab)) + highlightedTabIds.delete(alreadySelectedTab.id); + } + } + + log(`set selection by shift-click on ${tab.id}: `, configs.debug && Array.from(targetTabs, dumpTab)); + for (const toBeSelectedTab of targetTabs) { + highlightedTabIds.add(toBeSelectedTab.id); + } + + const rootTabs = [tab]; + if (tab != activeTab && + !mIsInSelectionSession) + rootTabs.push(activeTab); + for (const root of rootTabs) { + if (!root.$TST.subtreeCollapsed) + continue; + for (const descendant of root.$TST.descendants) { + highlightedTabIds.add(descendant.id); + } + } + log(' => highlightedTabIds: ', highlightedTabIds); + + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_HIGHLIGHT_TABS, + tabIds: [...highlightedTabIds], + inheritToCollapsedDescendants: false, + }); + } + catch(_e) { // not implemented on old Firefox + return false; + } + mIsInSelectionSession = true; + return true; + } + else if (ctrlKeyPressed) { + try { + log(`change selection by ctrl-click on ${tab.id}`); + /* Special operation to toggle selection of collapsed descendants for the active tab. + - When there is no other multiselected foreign tab + => toggle multiselection only descendants. + - When there is one or more multiselected foreign tab + => toggle multiselection of the active tab and descendants. + => one of multiselected foreign tabs will be activated. + - When a foreign tab is highlighted and there is one or more unhighlighted descendants + => highlight all descendants (to prevent only the root tab is dragged). + */ + const activeTabDescendants = activeTab.$TST.descendants; + let toBeHighlighted = !tab.highlighted; + log('toBeHighlighted: ', toBeHighlighted); + if (tab == activeTab && + tab.$TST.subtreeCollapsed && + activeTabDescendants.length > 0) { + const highlightedCount = countMatched(activeTabDescendants, tab => tab.highlighted); + const partiallySelected = highlightedCount != 0 && highlightedCount != activeTabDescendants.length; + toBeHighlighted = partiallySelected || !activeTabDescendants[0].highlighted; + log(' => ', toBeHighlighted, { partiallySelected }); + } + if (toBeHighlighted) + highlightedTabIds.add(tab.id); + else + highlightedTabIds.delete(tab.id); + + if (tab.$TST.subtreeCollapsed) { + const descendants = tab == activeTab ? activeTabDescendants : tab.$TST.descendants; + for (const descendant of descendants) { + if (toBeHighlighted) + highlightedTabIds.add(descendant.id); + else + highlightedTabIds.delete(descendant.id); + } + } + + if (tab == activeTab) { + if (highlightedTabIds.size == 0) { + log('Don\'t unhighlight only one highlighted active tab!'); + highlightedTabIds.add(tab.id); + } + } + else if (!mIsInSelectionSession) { + log('Select active tab and its descendants, for new selection session'); + highlightedTabIds.add(activeTab.id); + if (activeTab.$TST.subtreeCollapsed) { + for (const descendant of activeTabDescendants) { + highlightedTabIds.add(descendant.id); + } + } + } + + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_HIGHLIGHT_TABS, + tabIds: [...highlightedTabIds], + inheritToCollapsedDescendants: false, + }); + } + catch(_e) { // not implemented on old Firefox + return false; + } + mLastClickedTab = tab; + mIsInSelectionSession = true; + return true; + } + else { + mLastClickedTab = null; + mIsInSelectionSession = false; + return false; + } +} + +Tab.onActivated.addListener((tab, _info = {}) => { + if (tab.windowId != mTargetWindow) + return; + + if (mLastClickedTab && + tab.id != mLastClickedTab.id && + Tab.getHighlightedTabs(mTargetWindow).length == 1) { + mLastClickedTab = null; + mIsInSelectionSession = false; + } +}); + +function onClick(_event) { + // clear unexpectedly left "dragging" state + // (see also https://github.com/piroor/treestyletab/issues/1921 ) + DragAndDrop.clearDraggingItemsState(); +} +onClick = EventUtils.wrapWithErrorHandler(onClick); + +function onAuxClick(event) { + if (event.button != 1) { + return; + } + // This is required to prevent new tab from middle-click on a UI link. + event.stopPropagation(); + event.preventDefault(); +} +onAuxClick = EventUtils.wrapWithErrorHandler(onAuxClick); + +function onDragStart(event) { + log('onDragStart ', event); + mLastDragStartTimestamp = Date.now(); + + if (!event.target.closest('.newtab-button')) { + log('not a draggable item in the tab bar'); + return; + } + + const modifiers = String(configs.newTabButtonDragGestureModifiers).toLowerCase(); + if (!configs.allowDragNewTabButton || + modifiers.includes('alt') != event.altKey || + modifiers.includes('ctrl') != event.ctrlKey || + modifiers.includes('meta') != event.metaKey || + modifiers.includes('shift') != event.shiftKey) { + log('not allowed drag action on the new tab button'); + event.stopPropagation(); + event.preventDefault(); + return; + } + + log('new tab button is going to be dragged'); + + const selector = document.getElementById(configs.longPressOnNewTabButton); + if (selector && + selector.ui.opened) { + log('menu is shown: don\'t start dragging'); + event.stopPropagation(); + event.preventDefault(); + return; + } + + const dt = event.dataTransfer; + dt.effectAllowed = 'copy'; + dt.setData('text/uri-list', 'about:newtab'); +} +onDragStart = EventUtils.wrapWithErrorHandler(onDragStart); + +function handleNewTabAction(event, options = {}) { + log('handleNewTabAction ', { event, options }); + + if (!configs.autoAttach && !('action' in options)) + options.action = Constants.kNEWTAB_DO_NOTHING; + + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_NEW_TAB_AS, + baseTabId: Tab.getActiveTab(mTargetWindow).id, + as: options.action, + cookieStoreId: options.cookieStoreId, + inBackground: event.shiftKey, + url: options.url, + }); +} + +async function onDblClick(event) { + if (EventUtils.isEventFiredOnNewTabButton(event)) + return; + + const tab = EventUtils.getTreeItemFromEvent(event, { force: true }) || EventUtils.getTreeItemFromTabbarEvent(event, { force: true }); + const livingTab = EventUtils.getTreeItemFromEvent(event); + log('dblclick tab: ', tab, { living: !!livingTab }); + + if (livingTab && + !EventUtils.isEventFiredOnTwisty(event) && + !EventUtils.isEventFiredOnSoundButton(event)) { + const detail = EventUtils.getMouseEventDetail(event, livingTab); + const extraContentsInfo = TSTAPIFrontend.getOriginalExtraContentsTarget(event); + const allowed = await TSTAPIFrontend.tryMouseOperationAllowedWithExtraContents( + TSTAPI.kNOTIFY_EXTRA_CONTENTS_DBLCLICKED, + TSTAPI.kNOTIFY_TAB_DBLCLICKED, + { detail, tab: livingTab }, + extraContentsInfo + ); + if (!allowed) + return; + + if (!EventUtils.isEventFiredOnClosebox(event) && // closebox action is already processed by onclick listener, so we should not handle it here! + configs.treeDoubleClickBehavior != Constants.kTREE_DOUBLE_CLICK_BEHAVIOR_NONE) { + switch (configs.treeDoubleClickBehavior) { + case Constants.kTREE_DOUBLE_CLICK_BEHAVIOR_TOGGLE_COLLAPSED: + //event.stopPropagation(); + //event.preventDefault(); + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_SET_SUBTREE_COLLAPSED_STATE, + tabId: livingTab.id, + collapsed: !livingTab.$TST.subtreeCollapsed, + manualOperation: true, + stack: configs.debug && new Error().stack + }); + break; + + case Constants.kTREE_DOUBLE_CLICK_BEHAVIOR_TOGGLE_STICKY: + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_TOGGLE_STICKY, + tabId: livingTab.id, + stack: configs.debug && new Error().stack + }); + break; + + case Constants.kTREE_DOUBLE_CLICK_BEHAVIOR_CLOSE: + //event.stopPropagation(); + //event.preventDefault(); + const tabIds = [livingTab.id]; + await Scroll.tryLockPosition(tabIds, Scroll.LOCK_REASON_REMOVE); + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_REMOVE_TABS_BY_MOUSE_OPERATION, + tabIds + }); + break; + } + } + return; + } + + if (tab) // ignore dblclick on closing tab or something + return; + + const detail = EventUtils.getMouseEventDetail(event, null); + const extraContentsInfo = TSTAPIFrontend.getOriginalExtraContentsTarget(event); + const allowed = await TSTAPIFrontend.tryMouseOperationAllowedWithExtraContents( + TSTAPI.kNOTIFY_EXTRA_CONTENTS_DBLCLICKED, + null, + { detail }, + extraContentsInfo + ); + if (!allowed) + return; + + //event.stopPropagation(); + //event.preventDefault(); + handleNewTabAction(event, { + action: configs.autoAttachOnNewTabCommand + }); +} + + + +function onNewTabActionSelect(item, event) { + if (item.dataset.value) { + let action; + switch (item.dataset.value) { + default: + action = Constants.kNEWTAB_OPEN_AS_ORPHAN; + break; + case 'child': + const hints = new Set([ + configs.autoAttachOnNewTabButtonMiddleClick, + configs.autoAttachOnNewTabButtonAccelClick, + ]); + if (configs.autoAttachOnNewTabCommand == Constants.kNEWTAB_OPEN_AS_CHILD_TOP) + action = Constants.kNEWTAB_OPEN_AS_CHILD_TOP; + else if (configs.autoAttachOnNewTabCommand == Constants.kNEWTAB_OPEN_AS_CHILD_END) + action = Constants.kNEWTAB_OPEN_AS_CHILD_END; + else if (hints.has(Constants.kNEWTAB_OPEN_AS_CHILD_TOP)) + action = Constants.kNEWTAB_OPEN_AS_CHILD_TOP; + else if (hints.has(Constants.kNEWTAB_OPEN_AS_CHILD_END)) + action = Constants.kNEWTAB_OPEN_AS_CHILD_END; + else + action = Constants.kNEWTAB_OPEN_AS_CHILD; + break; + case 'sibling': + action = Constants.kNEWTAB_OPEN_AS_SIBLING; + break; + case 'next-sibling': + action = Constants.kNEWTAB_OPEN_AS_NEXT_SIBLING; + break; + } + handleNewTabAction(event, { action }); + } + mNewTabActionSelector.ui.close(); +} + +function onContextualIdentitySelect(item, event) { + if (item.dataset.value) { + const action = EventUtils.isAccelAction(event) ? + configs.autoAttachOnNewTabButtonMiddleClick : + configs.autoAttachOnNewTabCommand; + handleNewTabAction(event, { + action, + cookieStoreId: item.dataset.value + }); + } + if (mContextualIdentitySelector.ui) + mContextualIdentitySelector.ui.close(); +} + + +function onMessage(message, _sender, _respond) { + if (!message || + typeof message.type != 'string' || + message.type.indexOf('ws:') != 0) + return; + + //log('onMessage: ', message, sender); + switch (message.type) { + case TSTAPI.kCOMMAND_BROADCAST_API_REGISTERED: + wait(0).then(() => { // wait until addons are updated + updateSpecialEventListenersForAPIListeners(); + }); + break; + + case TSTAPI.kCOMMAND_BROADCAST_API_UNREGISTERED: + wait(0).then(() => { // wait until addons are updated + updateSpecialEventListenersForAPIListeners(); + }); + break; + } +} + +function onBackgroundMessage(message) { + switch (message.type) { + case Constants.kNOTIFY_TAB_MOUSEDOWN_EXPIRED: + if (message.windowId == mTargetWindow) { + const lastMousedown = EventUtils.getLastMousedown(message.button || 0); + if (lastMousedown) + lastMousedown.expired = true; + } + break; + + case Constants.kCOMMAND_SHOW_CONTAINER_SELECTOR: { + if (!mContextualIdentitySelector.ui) + return; + const anchor = document.querySelector(` + :root.contextual-identity-selectable .contextual-identities-selector-anchor, + .newtab-button + `); + mContextualIdentitySelector.ui.open({ anchor }); + }; break; + } +} diff --git a/waterfox/browser/components/sidebar/sidebar/notifications.js b/waterfox/browser/components/sidebar/sidebar/notifications.js new file mode 100644 index 000000000000..deeee785dcb7 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/notifications.js @@ -0,0 +1,64 @@ +/* +# 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'; + +import { + configs, +} from '/common/common.js'; + +const mNotificationsContainer = document.querySelector('#notifications'); + +export function add(id, { message, contents, onCreated } = {}) { + const elementId = `notification_${id}`; + let notification = document.getElementById(elementId); + if (!notification) { + notification = document.createElement('span'); + notification.id = elementId; + } + + if (contents) { + const range = document.createRange(); + range.selectNodeContents(notification); + range.deleteContents(); + range.detach(); + notification.appendChild(contents); + } + else if (message) { + notification.textContent = message; + } + + if (notification.parentNode) + return notification; + + mNotificationsContainer.appendChild(notification); + + if (typeof onCreated == 'function') + onCreated(notification); + + if (mNotificationsContainer.childNodes.length > 0) { + mNotificationsContainer.classList.remove('hiding'); + mNotificationsContainer.classList.add('shown'); + } + + return notification; +} + +export function remove(id) { + const elementId = `notification_${id}`; + const existingNotification = document.getElementById(elementId); + if (!existingNotification) + return; + + existingNotification.parentNode.removeChild(existingNotification); + if (mNotificationsContainer.childNodes.length > 0) + return; + + mNotificationsContainer.classList.add('hiding'); + mNotificationsContainer.classList.remove('shown'); + setTimeout(() => { + mNotificationsContainer.classList.remove('hiding'); + }, configs.collapseDuration); +} diff --git a/waterfox/browser/components/sidebar/sidebar/pinned-tabs.js b/waterfox/browser/components/sidebar/sidebar/pinned-tabs.js new file mode 100644 index 000000000000..233d1f3dcda3 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/pinned-tabs.js @@ -0,0 +1,388 @@ +/* ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is the Tree Style Tab. + * + * The Initial Developer of the Original Code is YUKI "Piro" Hiroshi. + * Portions created by the Initial Developer are Copyright (C) 2011-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): YUKI "Piro" Hiroshi + * wanabe + * Tetsuharu OHZEKI + * Xidorn Quan (Firefox 40+ support) + * lv7777 (https://github.com/lv7777) + * + * ***** END LICENSE BLOCK ******/ +'use strict'; + +import { + log as internalLogger, + configs, + isRTL, +} from '/common/common.js'; + +import * as Constants from '/common/constants.js'; +import * as TabsStore from '/common/tabs-store.js'; + +import { Tab } from '/common/TreeItem.js'; + +import * as BackgroundConnection from './background-connection.js'; +import * as GapCanceller from './gap-canceller.js'; +import * as SidebarItems from './sidebar-items.js'; +import * as Size from './size.js'; + +function log(...args) { + internalLogger('sidebar/pinned-tabs', ...args); +} + +let mTargetWindow; +let mContentsHeight = 0; +let mAreaHeight = 0; +let mMaxVisibleRows = 0; +let mMaxCol = 0; +let mMaxColLastRow = 0; +let mMaxRow = 0; +const mTabsMatrix = new Map(); +let mDragStartY = 0; +let mDragStartHeight = 0; +let mFixedContainerHeight = -1; + +const mContainerResizer = document.querySelector('#pinned-tabs-container-resizer'); + +export async function init() { + mTargetWindow = TabsStore.getCurrentWindowId(); + browser.runtime.onMessage.addListener(onMessage); + const restoredFixedHeight = await browser.sessions.getWindowValue(mTargetWindow, 'pinned-container-fixed-height'); + if (typeof restoredFixedHeight == 'number') { + const allTabsAreaHeight = Size.getAllTabsAreaSize() + GapCanceller.getOffset(); + mFixedContainerHeight = Math.min(restoredFixedHeight, (allTabsAreaHeight || window.innerHeight) * 0.9); + } +} + +function getTabHeight() { + return configs.faviconizePinnedTabs ? Size.getRenderedFavIconizedTabHeight() : Size.getRenderedTabHeight(); +} + +export function reposition(options = {}) { + //log('reposition'); + const pinnedTabs = Tab.getPinnedTabs(mTargetWindow); + if (pinnedTabs.length == 0) { + reset(); + document.documentElement.classList.remove('have-pinned-tabs'); + return; + } + + document.documentElement.classList.add('have-pinned-tabs'); + + const maxWidth = Size.getPinnedTabsContainerWidth(); + const faviconized = configs.faviconizePinnedTabs; + + const xOffset = faviconized ? 0 : Size.getFavIconizedTabXOffset(); + const yOffset = faviconized ? Size.getFavIconizedTabYOffset() : Size.getTabYOffset(); + + const width = faviconized ? Size.getRenderedFavIconizedTabWidth() : maxWidth + xOffset; + const height = getTabHeight(); + const maxCol = faviconized ? Math.max( + 1, + configs.maxFaviconizedPinnedTabsInOneRow > 0 ? + configs.maxFaviconizedPinnedTabsInOneRow : + Math.floor(maxWidth / width) + ) : 1; + const maxRow = Math.ceil(pinnedTabs.length / maxCol); + + const pinnedTabsAreaRatio = Math.min(Math.max(0, configs.maxPinnedTabsRowsAreaPercentage), 100) / 100; + const allTabsAreaHeight = Size.getAllTabsAreaSize() + GapCanceller.getOffset(); + mMaxVisibleRows = Math.max(1, Math.floor((allTabsAreaHeight * pinnedTabsAreaRatio) / height)); + mContentsHeight = height * maxRow + yOffset; + mAreaHeight = Math.max( + height, + mFixedContainerHeight < 0 ? + Math.min( + mContentsHeight, + mMaxVisibleRows * height + ) : + Math.min( + mFixedContainerHeight, + allTabsAreaHeight * 0.9 + ) + ); + document.documentElement.style.setProperty('--pinned-tab-width', `${width}px`); + document.documentElement.style.setProperty('--pinned-tabs-area-size', `${mAreaHeight}px`); + if (configs.faviconizePinnedTabs && configs.maxFaviconizedPinnedTabsInOneRow > 0) + document.documentElement.style.setProperty('--pinned-tabs-max-column', configs.maxFaviconizedPinnedTabsInOneRow); + else + document.documentElement.style.removeProperty('--pinned-tabs-max-column'); + + Size.updateContainers(); + mTabsMatrix.clear(); + + let count = 0; + let row = 0; + let col = 0; + mMaxCol = 0; + mMaxColLastRow = 0; + mMaxRow = 0; + for (const tab of pinnedTabs) { + mMaxCol = Math.max(col, mMaxCol); + mMaxRow = row; + + count++; + if (options.justNow) + tab.$TST.removeState(Constants.kTAB_STATE_ANIMATION_READY); + + tab.$TST.toggleState(Constants.kTAB_STATE_FAVICONIZED, faviconized); + tab.$TST.toggleState(Constants.kTAB_STATE_LAST_ROW, row == maxRow - 1); + + if (row == maxRow - 1) + mMaxColLastRow = col; + + if (options.justNow) + tab.$TST.addState(Constants.kTAB_STATE_ANIMATION_READY); + + mTabsMatrix.set(`${col}:${row}`, tab.id); + + /* + log('pinned tab: ', { + tab: dumpTab(tab), + col: col, + width: width, + height: height + }); + */ + + col++; + if (count > 0 && + count % maxCol == 0) { + row++; + col = 0; + //log('=> new row'); + } + } + log('reposition: ', { maxWidth, faviconized, width, height, maxCol, maxRow, pinnedTabsAreaRatio, allTabsAreaHeight, xOffset, yOffset, mMaxVisibleRows, mAreaHeight }); + log('overflow: mContentsHeight > mAreaHeight : ', mContentsHeight > mAreaHeight); + SidebarItems.pinnedContainer.classList.toggle('overflow', mContentsHeight > mAreaHeight); +} + +export function reserveToReposition(options = {}) { + if (reserveToReposition.waiting) + clearTimeout(reserveToReposition.waiting); + reserveToReposition.waiting = setTimeout(() => { + delete reserveToReposition.waiting; + reposition(options); + }, 10); +} + +function reset() { + document.documentElement.style.setProperty('--pinned-tabs-area-size', '0px'); + for (const tab of Tab.getPinnedTabs(mTargetWindow, { iterator: true })) { + clearStyle(tab); + } + mAreaHeight = 0; + mMaxVisibleRows = 0; + mMaxCol = 0; + mMaxColLastRow = 0; + mMaxRow = 0; + mTabsMatrix.clear(); + Size.updateContainers(); +} + +function clearStyle(tab) { + tab.$TST.removeState(Constants.kTAB_STATE_FAVICONIZED); + tab.$TST.removeState(Constants.kTAB_STATE_LAST_ROW); +} + +function getTabPosition(tab) { + if (!tab) + throw new Error('missing tab'); + + log('getTabPosition from ', [...mTabsMatrix.keys()]); + for (const [position, tabId] of mTabsMatrix.entries()) { + if (tabId != tab.id) + continue; + const [col, row] = position.split(':'); + log(` => ${col}:${row}`); + return { + col: parseInt(col), + row: parseInt(row), + }; + } + + throw new Error(`no pinned tab with id ${tab.id}`); +} + +// This must be synchronous and return Promise on demando, to avoid +// blocking to other listeners. +function onMessage(message, _sender, _respond) { + if (!message || + typeof message.type != 'string' || + message.type.indexOf('ws:') != 0) + return; + + if (message.windowId && + message.windowId != mTargetWindow) + return; + + //log('onMessage: ', message, sender); + switch (message.type) { + case Constants.kCOMMAND_GET_ABOVE_TAB: { + try { + const { col, row } = getTabPosition(Tab.get(message.tabId)); + const nextRow = row - 1; + log(`above tab: ${col}:${row} => ${col}:${nextRow}`); + return Promise.resolve( + mTabsMatrix.get(`${col}:${nextRow}`) || + null + ); + } + catch(_error) { + return Promise.resolve( + mTabsMatrix.get(`0:${mMaxRow}`) || + null + ); + } + }; break; + + case Constants.kCOMMAND_GET_BELOW_TAB: { + const { col, row } = getTabPosition(Tab.get(message.tabId)); + const nextRow = row + 1; + log(`below tab: ${col}:${row} => ${col}:${nextRow}`); + return Promise.resolve( + mTabsMatrix.get(`${col}:${nextRow}`) || + mTabsMatrix.get(`${mMaxColLastRow}:${nextRow}`) || + null + ); + }; break; + + case Constants.kCOMMAND_GET_LEFT_TAB: + return isRTL() ? getTabInlineNextTab(message) : getTabInlinePreviousTab(message); + + case Constants.kCOMMAND_GET_RIGHT_TAB: + return isRTL() ? getTabInlinePreviousTab(message) : getTabInlineNextTab(message); + } +} + +function getTabInlinePreviousTab(message) { + const { col, row } = getTabPosition(Tab.get(message.tabId)); + const maxCol = row == mMaxRow ? mMaxColLastRow : mMaxCol; + const nextCol = col == 0 ? maxCol : col - 1; + log(`left tab: ${col}:${row} => ${nextCol}:${row}`); + return Promise.resolve( + mTabsMatrix.get(`${nextCol}:${row}`) || + null + ); +} + +function getTabInlineNextTab(message) { + const { col, row } = getTabPosition(Tab.get(message.tabId)); + const maxCol = row == mMaxRow ? mMaxColLastRow : mMaxCol; + const nextCol = col == maxCol ? 0 : col + 1; + log(`right tab: ${col}:${row} => ${nextCol}:${row}`); + return Promise.resolve( + mTabsMatrix.get(`${nextCol}:${row}`) || + null + ); +} + +const BUFFER_KEY_PREFIX = 'pinned-tabs-'; + +BackgroundConnection.onMessage.addListener(async message => { + switch (message.type) { + case Constants.kCOMMAND_NOTIFY_TAB_CREATED: { + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + if (!tab) + return; + if (tab.pinned) + reserveToReposition(); + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_REMOVING: + case Constants.kCOMMAND_NOTIFY_TAB_MOVED: + case Constants.kCOMMAND_NOTIFY_TAB_INTERNALLY_MOVED: { + // don't wait until tracked here, because removing or detaching tab will become untracked! + const tab = Tab.get(message.tabId); + if (tab?.pinned) + reserveToReposition(); + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_SHOWN: + case Constants.kCOMMAND_NOTIFY_TAB_HIDDEN: + reserveToReposition(); + break; + + case Constants.kCOMMAND_NOTIFY_TAB_DETACHED_FROM_WINDOW: + if (message.wasPinned) + reserveToReposition(); + break; + + case Constants.kCOMMAND_NOTIFY_TAB_PINNED: + case Constants.kCOMMAND_NOTIFY_TAB_UNPINNED: { + if (BackgroundConnection.handleBufferedMessage({ type: 'pinned/unpinned', message }, `${BUFFER_KEY_PREFIX}${message.tabId}`)) + return; + await Tab.waitUntilTracked(message.tabId, { element: true }); + const tab = Tab.get(message.tabId); + const lastMessage = BackgroundConnection.fetchBufferedMessage('pinned/unpinned', `${BUFFER_KEY_PREFIX}${message.tabId}`); + if (!tab || + !lastMessage) + return; + if (lastMessage.message.type == Constants.kCOMMAND_NOTIFY_TAB_UNPINNED) + clearStyle(tab); + reserveToReposition(); + }; break; + } +}); + +mContainerResizer.addEventListener('mousedown', event => { + event.stopPropagation(); + event.preventDefault(); + mContainerResizer.setCapture(true); + mDragStartY = event.clientY; + mDragStartHeight = mAreaHeight; + mContainerResizer.addEventListener('mousemove', onMouseMove); +}); + +mContainerResizer.addEventListener('mouseup', event => { + mContainerResizer.removeEventListener('mousemove', onMouseMove); + event.stopPropagation(); + event.preventDefault(); + document.releaseCapture(); + mFixedContainerHeight = Math.max( + getTabHeight(), + Math.min( + Math.max(0, mDragStartHeight + (event.clientY - mDragStartY)), + mContentsHeight + ) + ); + reposition(); + saveLastHeight(); +}); + +function onMouseMove(event) { + event.stopPropagation(); + event.preventDefault(); + mFixedContainerHeight = Math.max(0, mDragStartHeight + (event.clientY - mDragStartY)); + reposition(); +} + +function saveLastHeight() { + browser.sessions.setWindowValue(mTargetWindow, 'pinned-container-fixed-height', mFixedContainerHeight); +} + +mContainerResizer.addEventListener('dblclick', async event => { + event.stopPropagation(); + event.preventDefault(); + mFixedContainerHeight = -1; + reposition(); + saveLastHeight(); +}); diff --git a/waterfox/browser/components/sidebar/sidebar/restoring-tab-count.js b/waterfox/browser/components/sidebar/sidebar/restoring-tab-count.js new file mode 100644 index 000000000000..231551c8938c --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/restoring-tab-count.js @@ -0,0 +1,35 @@ +/* +# 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'; + +import * as BackgroundConnection from './background-connection.js'; +import * as Constants from '/common/constants.js'; + +let mCount = 0; + +export function increment() { + mCount++; +} + +export function decrement() { + mCount--; +} + +export function hasMultipleRestoringTabs() { + return mCount > 1; +} + +BackgroundConnection.onMessage.addListener(async message => { + switch (message.type) { + case Constants.kCOMMAND_NOTIFY_TAB_RESTORING: + increment(); + break; + + case Constants.kCOMMAND_NOTIFY_TAB_RESTORED: + decrement(); + break; + } +}); diff --git a/waterfox/browser/components/sidebar/sidebar/scroll.js b/waterfox/browser/components/sidebar/sidebar/scroll.js new file mode 100644 index 000000000000..483293e20cfb --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/scroll.js @@ -0,0 +1,1710 @@ +/* ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1 + * + * The contents of this file are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is the Tree Style Tab. + * + * The Initial Developer of the Original Code is YUKI "Piro" Hiroshi. + * Portions created by the Initial Developer are Copyright (C) 2011-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): YUKI "Piro" Hiroshi + * wanabe + * Tetsuharu OHZEKI + * Xidorn Quan (Firefox 40+ support) + * lv7777 (https://github.com/lv7777) + * + * ***** END LICENSE BLOCK ******/ +'use strict'; + +/* ***** IMPORTANT NOTE FOR BETTER PERFORMANCE ***** + Functions in this module will be called very frequently while + scrolling. We should not do operations causing style computation + like calling getBoundingClientRect() or accessing to + offsetWidth/Height/Top/Left. Instead use Size.getXXXXX() methods + which return statically calculated sizes. If you need to get + something more new size, add a logic to calculate it to + Size.updateTabs() or Size.updateContainers(). + ************************************************* */ + +import EventListenerManager from '/extlib/EventListenerManager.js'; +import { SequenceMatcher } from '/extlib/diff.js'; + +import { + log as internalLogger, + wait, + nextFrame, + configs, + shouldApplyAnimation, + watchOverflowStateChange, + mapAndFilter, +} from '/common/common.js'; + +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from '/common/constants.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TSTAPI from '/common/tst-api.js'; + +import { Tab, TabGroup, TreeItem } from '/common/TreeItem.js'; + +import * as BackgroundConnection from './background-connection.js'; +import * as CollapseExpand from './collapse-expand.js'; +import * as EventUtils from './event-utils.js'; +import * as RestoringTabCount from './restoring-tab-count.js'; +import * as SidebarItems from './sidebar-items.js'; +import * as Size from './size.js'; + +export const onPositionUnlocked = new EventListenerManager(); +export const onVirtualScrollViewportUpdated = new EventListenerManager(); +export const onNormalTabsOverflow = new EventListenerManager(); +export const onNormalTabsUnderflow = new EventListenerManager(); + +function log(...args) { + internalLogger('sidebar/scroll', ...args); +} + + +export const LOCK_REASON_REMOVE = 'remove'; +export const LOCK_REASON_COLLAPSE = 'collapse'; + +const mPinnedScrollBox = document.querySelector('#pinned-tabs-container'); +const mNormalScrollBox = document.querySelector('#normal-tabs-container'); +const mTabBar = document.querySelector('#tabbar'); +const mOutOfViewTabNotifier = document.querySelector('#out-of-view-tab-notifier'); + +let mTabbarSpacerSize = 0; + +let mScrollingInternallyCount = 0; + +export function init(scrollPosition) { + // We should cached scroll positions, because accessing to those properties is slow. + mPinnedScrollBox.$scrollTop = 0; + mPinnedScrollBox.$scrollTopMax = mPinnedScrollBox.scrollTopMax; + mPinnedScrollBox.$offsetHeight = mPinnedScrollBox.offsetHeight; + mNormalScrollBox.$scrollTop = 0; + mNormalScrollBox.$scrollTopMax = mNormalScrollBox.scrollTopMax; + mNormalScrollBox.$offsetHeight = mNormalScrollBox.offsetHeight; + + // We need to register the lister as non-passive to cancel the event. + // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener#Improving_scrolling_performance_with_passive_listeners + document.addEventListener('wheel', onWheel, { capture: true, passive: false }); + mPinnedScrollBox.addEventListener('scroll', onScroll); + mNormalScrollBox.addEventListener('scroll', onScroll); + startObserveOverflowStateChange(); + browser.runtime.onMessage.addListener(onMessage); + BackgroundConnection.onMessage.addListener(onBackgroundMessage); + TSTAPI.onMessageExternal.addListener(onMessageExternal); + SidebarItems.onNormalTabsChanged.addListener(_tab => { + reserveToRenderVirtualScrollViewport({ trigger: 'tabsChanged' }); + }); + Tab.onNativeGroupModified.addListener(_tab => { + reserveToRenderVirtualScrollViewport({ trigger: 'tabsChanged' }); + }); + Size.onUpdated.addListener(() => { + mPinnedScrollBox.$scrollTopMax = mPinnedScrollBox.scrollTopMax; + mPinnedScrollBox.$offsetHeight = mPinnedScrollBox.offsetHeight; + mNormalScrollBox.$scrollTopMax = mNormalScrollBox.scrollTopMax; + mNormalScrollBox.$offsetHeight = mNormalScrollBox.offsetHeight; + reserveToRenderVirtualScrollViewport({ trigger: 'resized', force: true }); + }); + + reserveToRenderVirtualScrollViewport({ trigger: 'initialize' }); + if (typeof scrollPosition != 'number') + return; + + if (scrollPosition <= mNormalScrollBox.$scrollTopMax) { + mNormalScrollBox.scrollTop = + mNormalScrollBox.$scrollTop = Math.max(0, scrollPosition); + return; + } + + mScrollingInternallyCount++; + restoreScrollPosition.scrollPosition = scrollPosition; + onNormalTabsOverflow.addListener(onInitialOverflow); + onVirtualScrollViewportUpdated.addListener(onInitialUpdate); + wait(1000).then(() => { + onNormalTabsOverflow.removeListener(onInitialOverflow); + onVirtualScrollViewportUpdated.removeListener(onInitialUpdate); + if (restoreScrollPosition.scrollPosition != -1 && + mScrollingInternallyCount > 0) + mScrollingInternallyCount--; + restoreScrollPosition.scrollPosition = -1; + log('timeout: give up to restore scroll position'); + }); +} + +function startObserveOverflowStateChange() { + watchOverflowStateChange({ + target: mNormalScrollBox, + vertical: true, + moreResizeTargets: [ + // We need to watch resizing of the virtual scroll container to detect the changed state correctly. + mNormalScrollBox.querySelector('.virtual-scroll-container'), + ], + onOverflow() { onNormalTabsOverflow.dispatch(); }, + onUnderflow() { onNormalTabsUnderflow.dispatch(); }, + }); + + onNormalTabsOverflow.addListener(() => { + reserveToUpdateScrolledState(mNormalScrollBox); + }); + onNormalTabsUnderflow.addListener(() => { + reserveToUpdateScrolledState(mNormalScrollBox); + }); +} + +function onInitialOverflow() { + onNormalTabsOverflow.removeListener(onInitialOverflow); + onInitialOverflow.done = true; + if (onInitialUpdate.done) + restoreScrollPosition(); +} +function onInitialUpdate() { + onVirtualScrollViewportUpdated.removeListener(onInitialUpdate); + onInitialUpdate.done = true; + if (onInitialOverflow.done) + restoreScrollPosition(); +} +function restoreScrollPosition() { + if (restoreScrollPosition.retryCount < 10 && + restoreScrollPosition.scrollPosition > mNormalScrollBox.$scrollTopMax) { + restoreScrollPosition.retryCount++; + return window.requestAnimationFrame(restoreScrollPosition); + } + + if (restoreScrollPosition.scrollPosition <= mNormalScrollBox.$scrollTopMax) + mNormalScrollBox.scrollTop = + mNormalScrollBox.$scrollTop = Math.max( + 0, + restoreScrollPosition.scrollPosition + ); + restoreScrollPosition.scrollPosition = -1; + if (mScrollingInternallyCount > 0) { + window.requestAnimationFrame(() => { + if (mScrollingInternallyCount > 0) + mScrollingInternallyCount--; + }); + } +} +restoreScrollPosition.retryCount = 0; +restoreScrollPosition.scrollPosition = -1; + + +/* virtual scrolling */ + +export function reserveToRenderVirtualScrollViewport({ trigger, force } = {}) { + if (!force && + mScrollingInternallyCount > 0) + return; + + if (trigger) + renderVirtualScrollViewport.triggers.add(trigger); + + if (renderVirtualScrollViewport.invoked) + return; + renderVirtualScrollViewport.invoked = true; + window.requestAnimationFrame(() => renderVirtualScrollViewport()); +} + +let mLastRenderableItems; +let mLastDisappearingItems; +let mLastRenderedVirtualScrollItemIds = []; +const STICKY_SPACER_MATCHER = /^tab:(\d+):sticky$/; +let mScrollPosition = 0; + +export function getRenderableTreeItems(windowId = null) { + if (!windowId) { + windowId = TabsStore.getCurrentWindowId(); + } + if (TabsStore.nativelyGroupedTabsInWindow.get(windowId).size == 0) { + log('getRenderableTreeItems: no native tab group'); + return TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.virtualScrollRenderableTabsInWindow, windowId), + skipMatching: true, + ordered: true, + }); + } + + const mixedItems = TreeItem.sort([ + ...TabsStore.queryAll({ + windowId, + tabs: TabsStore.getTabsMap(TabsStore.virtualScrollRenderableTabsInWindow, windowId), + skipMatching: true, + }), + ...mapAndFilter( + [...TabsStore.windows.get(windowId).tabGroups.values()], + group => { + group.$TST.reindex(); + if (group.collapsed && + group.$TST.members.some(tab => tab.active)) { + const counterItem = group.$TST.collapsedMembersCounterItem; + counterItem.$TST.update(); + return [group, counterItem]; + } + return group; + } + ).flat(), + ]); + log('getRenderableTreeItems: mixedItems = ', mixedItems); + + return mixedItems; +}; + +renderVirtualScrollViewport.triggers = new Set(); + +function renderVirtualScrollViewport(scrollPosition = undefined) { + renderVirtualScrollViewport.invoked = false; + const triggers = new Set([...renderVirtualScrollViewport.triggers]); + renderVirtualScrollViewport.triggers.clear(); + + const startAt = Date.now(); + + const windowId = TabsStore.getCurrentWindowId(); + const win = TabsStore.windows.get(windowId); + if (!win || + !win.containerElement) + return; // not initialized yet + + + const outOfScreenPages = configs.outOfScreenTabsRenderingPages; + const staticRendering = outOfScreenPages < 0; + const skipRefreshItems = staticRendering && triggers.size == 1 && triggers.has('scroll'); + + const itemSize = Size.getRenderedTabHeight(); + const renderableItems = skipRefreshItems && mLastRenderableItems || getRenderableTreeItems(windowId); + const disappearingItems = skipRefreshItems && mLastDisappearingItems || renderableItems.filter(item => item.$TST.removing || item.$TST.states.has(Constants.kTAB_STATE_COLLAPSING)); + const totalRenderableItemsSize = Size.getTabMarginBlockStart() + (itemSize * (renderableItems.length - disappearingItems.length)) + Size.getTabMarginBlockEnd(); + const viewPortSize = Size.getNormalTabsViewPortSize(); + + if (staticRendering) { + mLastRenderableItems = renderableItems; + mLastDisappearingItems = disappearingItems; + } + + // For underflow case, we need to unset min-height to put the "new tab" + // button next to the last tab immediately. + // We need to set the style value directly instead of using custom properties, to reduce needless style computation. + mNormalScrollBox.querySelector('.virtual-scroll-container').style.minHeight = `${viewPortSize < totalRenderableItemsSize ? totalRenderableItemsSize : 0}px`; + + const totalItemsSizeHolder = win.containerElement.parentNode; + const resized = totalItemsSizeHolder.$lastHeight != totalRenderableItemsSize; + totalItemsSizeHolder.$lastHeight = totalRenderableItemsSize; + if (resized) { + mNormalScrollBox.$offsetHeight = mNormalScrollBox.offsetHeight; + mNormalScrollBox.$scrollTopMax = /*mNormalScrollBox.scrollTopMax*/Math.max(0, totalRenderableItemsSize - viewPortSize); + } + + const renderablePaddingSize = staticRendering ? + totalRenderableItemsSize : + viewPortSize * outOfScreenPages; + scrollPosition = Math.max( + 0, + Math.min( + totalRenderableItemsSize + mTabbarSpacerSize - viewPortSize, + typeof scrollPosition == 'number' ? + scrollPosition : + restoreScrollPosition.scrollPosition > -1 ? + restoreScrollPosition.scrollPosition : + mNormalScrollBox.$scrollTop + ) + ); + mScrollPosition = scrollPosition; + + const firstRenderableIndex = Math.max( + 0, + Math.floor((scrollPosition - renderablePaddingSize) / itemSize) + ); + const lastRenderableIndex = Math.max( + 0, + Math.min( + renderableItems.length - 1, + Math.ceil((scrollPosition + viewPortSize + renderablePaddingSize) / itemSize) + ) + ); + const renderedOffset = itemSize * firstRenderableIndex; + // We need to set the style value directly instead of using custom properties, to reduce needless style computation. + mNormalScrollBox.querySelector('.tabs').style.transform = staticRendering ? + '' : + `translateY(${renderedOffset}px)`; + // We need to shift contents one more, to cover the reduced height due to the sticky tab. + + if (resized) { + reserveToUpdateScrolledState(mNormalScrollBox) + onVirtualScrollViewportUpdated.dispatch(resized); + } + + const stickyItems = updateStickyItems(renderableItems, { staticRendering, skipRefreshItems }); + + if (skipRefreshItems) { + log('renderVirtualScrollViewport: skip re-rendering of items, rendered = ', renderableItems); + if (mLastRenderedVirtualScrollItemIds.length != renderableItems.length) { + mLastRenderedVirtualScrollItemIds = renderableItems.map(item => item.$TST.renderingId); + } + } + else { + const toBeRenderedItems = renderableItems.slice(firstRenderableIndex, lastRenderableIndex + 1); + const toBeRenderedItemIds = toBeRenderedItems.map(item => item.$TST.renderingId); + const toBeRenderedItemIdsSet = new Set(toBeRenderedItemIds); + for (const stickyItem of stickyItems) { + const id = stickyItem.$TST.renderingId; + if (toBeRenderedItemIdsSet.has(id)) { + toBeRenderedItemIds.splice(toBeRenderedItemIds.indexOf(id), 1, `${id}:sticky`); + } + } + + const renderOperations = (new SequenceMatcher(mLastRenderedVirtualScrollItemIds, toBeRenderedItemIds)).operations(); + log('renderVirtualScrollViewport ', { + firstRenderableIndex, + firstRenderableItemIndex: renderableItems[firstRenderableIndex]?.index, + lastRenderableIndex, + lastRenderableItemIndex: renderableItems[lastRenderableIndex]?.index, + old: mLastRenderedVirtualScrollItemIds.slice(0), + new: toBeRenderedItemIds.slice(0), + renderOperations, + scrollPosition, + viewPortSize, + totalRenderableItemsSize, + }); + + const toBeRenderedItemIdSet = new Set(toBeRenderedItemIds); + for (const operation of renderOperations) { + const [tag, fromStart, fromEnd, toStart, toEnd] = operation; + switch (tag) { + case 'equal': + break; + + case 'delete': { + const ids = mLastRenderedVirtualScrollItemIds.slice(fromStart, fromEnd); + //log('delete: ', { fromStart, fromEnd, toStart, toEnd }, ids); + for (const id of ids) { + if (STICKY_SPACER_MATCHER.test(id)) { + const spacer = win.containerElement.querySelector(`.sticky-tab-spacer[data-tab-id="${RegExp.$1}"]`); + if (spacer) + spacer.parentNode.removeChild(spacer); + continue; + } + const item = getRenderableItemById(id); + if (item?.$TST.element?.parentNode != win.containerElement) // already sticky + continue; + // We don't need to remove already rendered item, + // because it is automatically moved by insertBefore(). + if (toBeRenderedItemIdSet.has(id) || + !item || + !mNormalScrollBox.contains(item.$TST.element)) + continue; + SidebarItems.unrenderItem(item); + } + }; break; + + case 'insert': + case 'replace': { + const deleteIds = mLastRenderedVirtualScrollItemIds.slice(fromStart, fromEnd); + const insertIds = toBeRenderedItemIds.slice(toStart, toEnd); + //log('insert or replace: ', { fromStart, fromEnd, toStart, toEnd }, deleteIds, ' => ', insertIds); + for (const id of deleteIds) { + if (STICKY_SPACER_MATCHER.test(id)) { + const spacer = win.containerElement.querySelector(`.sticky-tab-spacer[data-tab-id="${RegExp.$1}"]`); + if (spacer) + spacer.parentNode.removeChild(spacer); + continue; + } + const item = getRenderableItemById(id); + if (item?.$TST.element?.parentNode != win.containerElement) // already sticky + continue; + // We don't need to remove already rendered item, + // because it is automatically moved by insertBefore(). + if (toBeRenderedItemIdSet.has(id) || + !item || + !mNormalScrollBox.contains(item.$TST.element)) + continue; + SidebarItems.unrenderItem(item); + } + const referenceItem = fromEnd < mLastRenderedVirtualScrollItemIds.length ? + getRenderableItemById(mLastRenderedVirtualScrollItemIds[fromEnd]) : + null; + const referenceItemHasValidReferenceElement = referenceItem?.$TST.element?.parentNode == win.containerElement; + for (const id of insertIds) { + if (STICKY_SPACER_MATCHER.test(id)) { + const spacer = document.createElement('li'); + spacer.classList.add('sticky-tab-spacer'); + spacer.setAttribute('data-tab-id', RegExp.$1); + win.containerElement.insertBefore( + spacer, + (referenceItem && win.containerElement.querySelector(`.sticky-tab-spacer[data-tab-id="${referenceItem.id}"]`)) || + (referenceItemHasValidReferenceElement && + referenceItem.$TST.element) || + null + ); + continue; + } + const item = getRenderableItemById(id); + SidebarItems.renderItem(item, { + insertBefore: referenceItemHasValidReferenceElement ? referenceItem : + (referenceItem && win.containerElement.querySelector(`.sticky-tab-spacer[data-tab-id="${referenceItem.id}"]`)) || + null, + }); + } + }; break; + } + } + mLastRenderedVirtualScrollItemIds = toBeRenderedItemIds; + } + + log(`${Date.now() - startAt} msec, offset = ${renderedOffset}`); +} +function getRenderableItemById(id) { + if (STICKY_SPACER_MATCHER.test(id)) { + return Tab.get(parseInt(RegExp.$1)); + } + + const [type, rawId] = id.split(':'); + switch (type) { + case TreeItem.TYPE_GROUP: + return TabGroup.get(parseInt(rawId)); + + case TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER: + return TabGroup.get(parseInt(rawId)).$TST.collapsedMembersCounterItem; + + case TreeItem.TYPE_TAB: + default: + return Tab.get(parseInt(rawId)); + } + + return null; +} + +let mLastStickyItemIdsAbove = new Set(); +let mLastStickyItemIdsBelow = new Set(); +let mLastCanBeStickyItems; + +function updateStickyItems(renderableItems, { staticRendering, skipRefreshItems } = {}) { + const itemSize = Size.getRenderedTabHeight(); + const windowId = TabsStore.getCurrentWindowId(); + const scrollPosition = mScrollPosition; + const viewPortSize = Size.getNormalTabsViewPortSize(); + + const firstInViewportIndex = Math.ceil(scrollPosition / itemSize); + const lastInViewportIndex = Math.floor((scrollPosition + viewPortSize - itemSize) / itemSize); + + const stickyItemIdsAbove = new Set(); + const stickyItemIdsBelow = new Set(); + const stickyItems = []; + + const canBeStickyItems = skipRefreshItems && mLastCanBeStickyItems || renderableItems.filter(item => item.$TST.canBecomeSticky); + log('canBeStickyItems ', canBeStickyItems); + if (staticRendering) + mLastCanBeStickyItems = canBeStickyItems; + + const removedOrCollapsedTabsCount = parseInt(mNormalScrollBox.querySelector(`.${Constants.kTABBAR_SPACER}`).dataset.removedOrCollapsedTabsCount || 0); + for (const item of canBeStickyItems.slice(0).reverse()) { // first try: find bottom sticky items from bottom + const index = renderableItems.indexOf(item); + if (index > -1 && + index > (lastInViewportIndex - stickyItemIdsBelow.size) && + mNormalScrollBox.$scrollTop < mNormalScrollBox.$scrollTopMax && + (index - (lastInViewportIndex - stickyItemIdsBelow.size) > 1 || + removedOrCollapsedTabsCount == 0)) { + stickyItemIdsBelow.add(item.id); + continue; + } + if (stickyItemIdsBelow.size > 0) + break; + } + + for (const item of canBeStickyItems) { // second try: find top sticky items and set bottom sticky items + const index = renderableItems.indexOf(item); + if (index > -1 && + index < (firstInViewportIndex + stickyItemIdsAbove.size) && + mNormalScrollBox.$scrollTop > 0) { + stickyItems.push(item); + stickyItemIdsAbove.add(item.id); + continue; + } + if (stickyItemIdsBelow.has(item.id)) { + stickyItems.push(item); + continue; + } + if (item.$TST.element && + item.$TST.element.parentNode != TabsStore.windows.get(windowId).containerElement) { + SidebarItems.unrenderItem(item); + continue; + } + } + + for (const [lastIds, currentIds, place] of [ + [[...mLastStickyItemIdsAbove], [...stickyItemIdsAbove], 'above'], + [[...mLastStickyItemIdsBelow].reverse(), [...stickyItemIdsBelow].reverse(), 'below'], + ]) { + const renderOperations = (new SequenceMatcher(lastIds, currentIds)).operations(); + for (const operation of renderOperations) { + const [tag, fromStart, fromEnd, toStart, toEnd] = operation; + switch (tag) { + case 'equal': + break; + + case 'delete': { + const ids = lastIds.slice(fromStart, fromEnd); + for (const id of ids) { + if (!stickyItemIdsAbove.has(id) && + !stickyItemIdsBelow.has(id)) + SidebarItems.unrenderItem(Tab.get(id)); + } + }; break; + + case 'insert': + case 'replace': { + const deleteIds = lastIds.slice(fromStart, fromEnd); + for (const id of deleteIds) { + if (!stickyItemIdsAbove.has(id) && + !stickyItemIdsBelow.has(id)) + SidebarItems.unrenderItem(Tab.get(id)); + } + const insertIds = currentIds.slice(toStart, toEnd); + const referenceItem = (fromEnd < lastIds.length && currentIds.includes(lastIds[fromEnd])) ? + Tab.get(lastIds[fromEnd]) : + null; + for (const id of insertIds) { + SidebarItems.renderItem(Tab.get(id), { + containerElement: document.querySelector(`.sticky-tabs-container.${place}`), + insertBefore: referenceItem, + }); + } + }; break; + } + } + } + + log('updateStickyItems ', stickyItems, { above: [...stickyItemIdsAbove], below: [...stickyItemIdsBelow] }); + mLastStickyItemIdsAbove = stickyItemIdsAbove; + mLastStickyItemIdsBelow = stickyItemIdsBelow; + + return stickyItems; +} + +function getScrollBoxFor(item, { allowFallback } = {}) { + if (!item || !item.pinned) + return mNormalScrollBox; // the default + if (allowFallback && + mPinnedScrollBox.$scrollTopMax == 0) { + log('pinned tabs are not scrollable, fallback to normal tabs'); + return mNormalScrollBox; + } + return mPinnedScrollBox; +} + +export function getItemRect(item, { afterAnimation } = {}) { + if (item.pinned) + return item.$TST.element.getBoundingClientRect(); + + let renderableItems; + if (afterAnimation) { + // We need to ignore preceding "going to be collapsed" tabs on determination of the + // final tab position. + const calculationTargetTabs = TabsStore.scrollPositionCalculationTargetTabsInWindow.get(item.windowId); + // On the other hand, preceding "going to be expanded" tabs are naturally included + // in the "renderable" tabs (because they are still visible in the tab bar). + const sourceRenderableItems = getRenderableTreeItems(item.windowId); + // So, we can get the collection of finally visible tabs with "renderable tabs" - "collapsing tabs". + renderableItems = mapAndFilter(sourceRenderableItems, item => { + if (item.type == 'tab' && !calculationTargetTabs.has(item.id)) { + return undefined; + } + return item.id; + }); + } + else { + renderableItems = getRenderableTreeItems(item.windowId).map(item => item.id); + } + const itemSize = Size.getTabHeight(); + const scrollBox = getScrollBoxFor(item); + const scrollBoxRect = Size.getScrollBoxRect(scrollBox); + + let index = renderableItems.indexOf(item.id); + if (index < 0) { // the item is not renderable yet, so we calculate the index based on other items. + const following = item.$TST.nearestVisibleFollowingTab; + if (following) { + index = renderableItems.indexOf(following.id); + } + else { + const preceding = item.$TST.nearestVisiblePrecedingTab; + if (preceding) { + index = renderableItems.indexOf(preceding.id); + if (index > -1) + index++; + } + } + if (index < -1) // no nearest visible item: treat as a last item + index = renderableItems.length; + } + const itemTop = Size.getRenderedTabHeight() * index + scrollBoxRect.top - scrollBox.$scrollTop; + /* + console.log('coordinates of item rect ', { + index, + renderableItemHeight: Size.getRenderedTabHeight(), + scrollBox_rectTop: scrollBoxRect.top, + scrollBox_$scrollTop: scrollBox.$scrollTop, + }); + */ + return { + top: itemTop, + bottom: itemTop + itemSize, + height: itemSize, + }; +} + +configs.$addObserver(key => { + switch (key) { + case 'outOfScreenTabsRenderingPages': + mLastRenderableItems = null; + mLastDisappearingItems = null; + mLastCanBeStickyItems = null; + break; + } +}); + + +/* basic operations */ + +function scrollTo(params = {}) { + log('scrollTo ', params); + if (!params.justNow && + shouldApplyAnimation(true) && + configs.smoothScrollEnabled) + return smoothScrollTo(params); + + //cancelPerformingAutoScroll(); + const scrollBox = params.scrollBox || getScrollBoxFor(params.item, { allowFallback: true }); + const scrollTop = params.item ? + scrollBox.$scrollTop + calculateScrollDeltaForItem(params.item) : + typeof params.position == 'number' ? + params.position : + typeof params.delta == 'number' ? + mNormalScrollBox.$scrollTop + params.delta : + undefined; + if (scrollTop === undefined) + throw new Error('No parameter to indicate scroll position'); + + // render before scroll, to prevent showing blank area + mScrollingInternallyCount++; + renderVirtualScrollViewport(scrollTop); + scrollBox.scrollTop = + scrollBox.$scrollTop = Math.min( + scrollBox.$scrollTopMax, + Math.max(0, scrollTop) + ); + window.requestAnimationFrame(() => { + if (mScrollingInternallyCount > 0) + mScrollingInternallyCount--; + }); +} + +function cancelRunningScroll() { + scrollToItem.stopped = true; + stopSmoothScroll(); +} + +function calculateScrollDeltaForItem(item, { over } = {}) { + item = TreeItem.get(item); + if (!item) + return 0; + + item = item.$TST.collapsed && item.$TST.nearestVisibleAncestorOrSelf || item; + + const itemRect = getItemRect(item, { afterAnimation: true }); + const scrollBox = getScrollBoxFor(item, { allowFallback: true }); + const scrollBoxRect = Size.getScrollBoxRect(scrollBox); + const overScrollOffset = over === false ? + 0 : + Math.ceil(Math.min( + itemRect.height / (item.pinned ? 3 : 2), + (scrollBoxRect.height - itemRect.height) / 3 + )); + let delta = 0; + if (scrollBoxRect.bottom < itemRect.bottom) { // should scroll down + delta = itemRect.bottom - scrollBoxRect.bottom + overScrollOffset; + if (!item.pinned) { + if (mLastStickyItemIdsBelow.has(item.id) && + mLastStickyItemIdsBelow.size > 0) + delta += itemRect.height * (mLastStickyItemIdsBelow.size - 1); + else + delta += itemRect.height * mLastStickyItemIdsBelow.size; + } + } + else if (scrollBoxRect.top > itemRect.top) { // should scroll up + delta = itemRect.top - scrollBoxRect.top - overScrollOffset; + if (!item.pinned) { + if (mLastStickyItemIdsAbove.has(item.id) && + mLastStickyItemIdsAbove.size > 0) + delta -= itemRect.height * (mLastStickyItemIdsAbove.size - 1); + else + delta -= itemRect.height * mLastStickyItemIdsAbove.size; + } + } + log('calculateScrollDeltaForItem ', item.id, { + delta, + itemTop: itemRect.top, + itemBottom: itemRect.bottom, + scrollBoxBottom: scrollBoxRect.bottom, + itemHeight: itemRect.height, + overScrollOffset, + }); + return delta; +} + +export function isItemInViewport(item, { allowPartial } = {}) { + item = Tab.get(item?.id); + if (!TabsStore.ensureLivingItem(item)) + return false; + + const itemRect = getItemRect(item, { afterAnimation: true }); + const allowedOffset = allowPartial ? (itemRect.height / 2) : 0; + const scrollBoxRect = Size.getScrollBoxRect(getScrollBoxFor(item)); + log('isItemInViewport ', item.id, { + allowedOffset, + itemTop: itemRect.top + allowedOffset, + itemBottom: itemRect.bottom - allowedOffset, + viewPortTop: scrollBoxRect.top, + viewPortBottom: scrollBoxRect.bottom, + }); + return ( + itemRect.top + allowedOffset >= scrollBoxRect.top && + itemRect.bottom - allowedOffset <= scrollBoxRect.bottom + ); +} + +async function smoothScrollTo(params = {}) { + log('smoothScrollTo ', params, new Error().stack); + //cancelPerformingAutoScroll(true); + + smoothScrollTo.stopped = false; + + const scrollBox = params.scrollBox || getScrollBoxFor(params.item, { allowFallback: true }); + + let delta, startPosition, endPosition; + if (params.item) { + startPosition = scrollBox.$scrollTop; + delta = calculateScrollDeltaForItem(params.item); + endPosition = startPosition + delta; + } + else if (typeof params.position == 'number') { + startPosition = scrollBox.$scrollTop; + endPosition = params.position; + delta = endPosition - startPosition; + } + else if (typeof params.delta == 'number') { + startPosition = scrollBox.$scrollTop; + endPosition = startPosition + params.delta; + delta = params.delta; + } + else { + throw new Error('No parameter to indicate scroll position'); + } + smoothScrollTo.currentOffset = delta; + + const duration = Math.max(0, typeof params.duration == 'number' ? params.duration : configs.smoothScrollDuration); + const startTime = Date.now(); + + return new Promise((resolve, _reject) => { + const radian = 90 * Math.PI / 180; + const scrollStep = () => { + if (smoothScrollTo.stopped) { + smoothScrollTo.currentOffset = 0; + //reject('smooth scroll is canceled'); + resolve(); + return; + } + const nowTime = Date.now(); + const spentTime = nowTime - startTime; + if (spentTime >= duration) { + scrollTo({ + scrollBox, + position: endPosition, + justNow: true + }); + smoothScrollTo.stopped = true; + smoothScrollTo.currentOffset = 0; + resolve(); + return; + } + const power = Math.sin(spentTime / duration * radian); + const currentDelta = parseInt(delta * power); + const newPosition = startPosition + currentDelta; + scrollTo({ + scrollBox, + position: newPosition, + justNow: true + }); + smoothScrollTo.currentOffset = currentDelta; + window.requestAnimationFrame(scrollStep); + }; + window.requestAnimationFrame(scrollStep); + }); +} +smoothScrollTo.currentOffset= 0; + +async function smoothScrollBy(delta) { + const scrollBox = getScrollBoxFor( + Tab.getActiveTab(TabsStore.getCurrentWindowId()), + { allowFallback: true } + ); + return smoothScrollTo({ + position: scrollBox.$scrollTop + delta, + scrollBox, + }); +} + +function stopSmoothScroll() { + smoothScrollTo.stopped = true; +} + +/* advanced operations */ + +export function scrollToNewTab(item, options = {}) { + if (!canScrollToItem(item)) + return; + + if (configs.scrollToNewTabMode == Constants.kSCROLL_TO_NEW_TAB_IF_POSSIBLE) { + const activeTab = Tab.getActiveTab(TabsStore.getCurrentWindowId()); + scrollToItem(item, { + ...options, + anchor: !activeTab.pinned && isItemInViewport(activeTab) && activeTab, + notifyOnOutOfView: true + }); + } +} + +function canScrollToItem(item) { + item = Tab.get(item?.id); + return ( + TabsStore.ensureLivingItem(item) && + !item.hidden + ); +} + +export async function scrollToItem(item, options = {}) { + scrollToItem.lastTargetId = null; + + log('scrollToItem to ', item?.id, ' anchor = ', options.anchor?.id, options, + { stack: configs.debug && new Error().stack }); + cancelRunningScroll(); + if (!canScrollToItem(item)) { + log('=> unscrollable'); + return; + } + + scrollToItem.stopped = false; + cancelNotifyOutOfViewItem(); + //cancelPerformingAutoScroll(true); + + await nextFrame(); + if (scrollToItem.stopped) + return; + cancelNotifyOutOfViewItem(); + + const anchorTab = options.anchor; + const hasAnchor = TabsStore.ensureLivingItem(anchorTab) && anchorTab != item; + const openedFromPinnedTab = hasAnchor && anchorTab.pinned; + + if (isItemInViewport(item) && + (!hasAnchor || + !openedFromPinnedTab)) { + log('=> already visible'); + return; + } + + // wait for one more frame, to start collapse/expand animation + await nextFrame(); + if (scrollToItem.stopped) + return; + cancelNotifyOutOfViewItem(); + scrollToItem.lastTargetId = item.id; + + const scrollBox = getScrollBoxFor(item); + if (hasAnchor && + !anchorTab.pinned) { + const targetItemRect = getItemRect(item, { afterAnimation: true }); + const anchorItemRect = getItemRect(anchorTab, { afterAnimation: true }); + const scrollBoxRect = Size.getScrollBoxRect(scrollBox); + let delta = calculateScrollDeltaForItem(item, { over: false }); + + const calculationTargetTabIds = new Set(TabsStore.scrollPositionCalculationTargetTabsInWindow.get(item.windowId).keys()); + const topStickyItems = anchorTab.$TST.precedingCanBecomeStickyTabs.filter(tab => calculationTargetTabIds.has(tab.id)); + const topStickyItemsAreaSize = Size.getRenderedTabHeight() * (topStickyItems.length - (mLastStickyItemIdsAbove.has(anchorTab.id) ? 1 : 0)); + const bottomStickyItems = item.$TST.followingCanBecomeStickyTabs.filter(tab => calculationTargetTabIds.has(tab.id)); + const bottomStickyItemsAreaSize = Size.getRenderedTabHeight() * (bottomStickyItems.length - (mLastStickyItemIdsBelow.has(item.id) ? 1 : 0)); + + if (targetItemRect.top > anchorItemRect.top) { + log('=> will scroll down'); + const boundingHeight = (targetItemRect.bottom + bottomStickyItemsAreaSize) - (anchorItemRect.top - topStickyItemsAreaSize); + const overHeight = boundingHeight - scrollBoxRect.height; + if (overHeight > 0) { + delta -= overHeight; + if (options.notifyOnOutOfView) + notifyOutOfViewItem(item); + } + log('calculated result: ', { + boundingHeight, overHeight, delta, + container: scrollBoxRect.height + }); + } + else if (targetItemRect.bottom < anchorItemRect.bottom) { + log('=> will scroll up'); + const boundingHeight = anchorItemRect.bottom - targetItemRect.top; + const overHeight = boundingHeight - scrollBoxRect.height; + if (overHeight > 0) + delta += overHeight; + log('calculated result: ', { + boundingHeight, overHeight, delta, + container: scrollBoxRect.height + }); + } + await scrollTo({ + ...options, + scrollBox, + position: scrollBox.$scrollTop + delta, + }); + } + else { + await scrollTo({ + ...options, + scrollBox, + item, + }); + } + // A tab can be moved after the tabbar is scrolled to the tab. + // To retry "scroll to tab" behavior for such cases, we need to + // keep "last scrolled-to tab" information until the tab is + // actually moved. + await wait(configs.tabBunchesDetectionTimeout); + if (scrollToItem.stopped) + return; + const retryOptions = { + retryCount: options.retryCount || 0, + anchor: options.anchor + }; + if (scrollToItem.lastTargetId == item.id && + !isItemInViewport(item) && + (!options.anchor || + !isItemInViewport(options.anchor)) && + retryOptions.retryCount < 3) { + retryOptions.retryCount++; + return scrollToItem(item, retryOptions); + } + if (scrollToItem.lastTargetId == item.id) + scrollToItem.lastTargetId = null; +} +scrollToItem.lastTargetId = null; + +/* +function scrollToItemSubtree(item) { + return scrollToItem(item.$TST.lastDescendant, { + anchor: item, + notifyOnOutOfView: true + }); +} + +function scrollToItems(items) { + return scrollToItem(items[items.length - 1], { + anchor: items[0], + notifyOnOutOfView: true + }); +} +*/ + +export function autoScrollOnMouseEvent(event) { + if (!event.target.closest || + autoScrollOnMouseEvent.invoked) + return; + + const scrollBox = event.target.closest(`#${mPinnedScrollBox.id}, #${mNormalScrollBox.id}`); + if (!scrollBox || + !scrollBox.classList.contains(Constants.kTABBAR_STATE_OVERFLOW)) + return; + + autoScrollOnMouseEvent.invoked = true; + window.requestAnimationFrame(() => { + autoScrollOnMouseEvent.invoked = false; + + const tabbarRect = Size.getScrollBoxRect(scrollBox); + const scrollPixels = Math.round(Size.getRenderedTabHeight() * 0.5); + if (event.clientY < tabbarRect.top + autoScrollOnMouseEvent.areaSize) { + if (scrollBox.$scrollTop > 0) + scrollBox.scrollTop = + scrollBox.$scrollTop = Math.min( + scrollBox.$scrollTopMax, + Math.max( + 0, + scrollBox.$scrollTop - scrollPixels + ) + ); + } + else if (event.clientY > tabbarRect.bottom - autoScrollOnMouseEvent.areaSize) { + if (scrollBox.$scrollTop < scrollBox.$scrollTopMax) + scrollBox.scrollTop = + scrollBox.$scrollTop = Math.min( + scrollBox.$scrollTopMax, + Math.max( + 0, + scrollBox.$scrollTop + scrollPixels + ) + ); + } + }); +} +autoScrollOnMouseEvent.areaSize = 20; + + +async function notifyOutOfViewItem(item) { + item = Tab.get(item?.id); + if (RestoringTabCount.hasMultipleRestoringTabs()) { + log('notifyOutOfViewItem: skip until completely restored'); + wait(100).then(() => notifyOutOfViewItem(item)); + return; + } + await nextFrame(); + cancelNotifyOutOfViewItem(); + if (item && isItemInViewport(item)) + return; + mOutOfViewTabNotifier.classList.add('notifying'); + await wait(configs.outOfViewTabNotifyDuration); + cancelNotifyOutOfViewItem(); +} + +function cancelNotifyOutOfViewItem() { + mOutOfViewTabNotifier.classList.remove('notifying'); +} + + +/* event handling */ + +async function onWheel(event) { + // Ctrl-WheelScroll produces zoom-in/out on all platforms + // including macOS (not Meta-WheelScroll!). + // Pinch-in/out on macOS also produces zoom-in/out and + // it is cancelable via synthesized `wheel` event. + // (See also: https://bugzilla.mozilla.org/show_bug.cgi?id=1777199#c5 ) + if (!configs.zoomable && + event.ctrlKey) { + event.preventDefault(); + return; + } + + const item = EventUtils.getTreeItemFromEvent(event); + const scrollBox = getScrollBoxFor(item, { allowFallback: true }); + + if (!TSTAPI.isScrollLocked()) { + cancelRunningScroll(); + if (EventUtils.getElementTarget(event).closest('.sticky-tabs-container') || + (item?.pinned && + scrollBox != mPinnedScrollBox)) { + event.stopImmediatePropagation(); + event.preventDefault(); + scrollTo({ delta: event.deltaY, scrollBox }); + } + return; + } + + event.stopImmediatePropagation(); + event.preventDefault(); + + TSTAPI.notifyScrolled({ + tab: item, + scrollContainer: scrollBox, + overflow: scrollBox.classList.contains(Constants.kTABBAR_STATE_OVERFLOW), + event + }); +} + +function onScroll(event) { + const scrollBox = event.currentTarget; + scrollBox.$scrollTopMax = scrollBox.scrollTopMax; + scrollBox.$scrollTop = Math.min(scrollBox.$scrollTopMax, scrollBox.scrollTop); + reserveToUpdateScrolledState(scrollBox); + if (scrollBox == mNormalScrollBox) { + reserveToRenderVirtualScrollViewport({ trigger: 'scroll' }); + } + reserveToSaveScrollPosition(); +} + + +function reserveToUpdateScrolledState(scrollBox) { + if (scrollBox.__reserveToUpdateScrolledState_invoked) // eslint-disable-line no-underscore-dangle + return; + scrollBox.__reserveToUpdateScrolledState_invoked = true; // eslint-disable-line no-underscore-dangle + window.requestAnimationFrame(() => { + scrollBox.__reserveToUpdateScrolledState_invoked = false; // eslint-disable-line no-underscore-dangle + + const scrolled = scrollBox.$scrollTop > 0; + const fullyScrolled = scrollBox.$scrollTop == scrollBox.$scrollTopMax; + scrollBox.classList.toggle(Constants.kTABBAR_STATE_SCROLLED, scrolled); + scrollBox.classList.toggle(Constants.kTABBAR_STATE_FULLY_SCROLLED, fullyScrolled); + + if (scrollBox == mNormalScrollBox) { + mTabBar.classList.toggle(Constants.kTABBAR_STATE_SCROLLED, scrolled); + mTabBar.classList.toggle(Constants.kTABBAR_STATE_FULLY_SCROLLED, fullyScrolled); + } + + Size.updateContainers(); + }); +} + +function reserveToSaveScrollPosition() { + if (reserveToSaveScrollPosition.reserved) + clearTimeout(reserveToSaveScrollPosition.reserved); + reserveToSaveScrollPosition.reserved = setTimeout(() => { + delete reserveToSaveScrollPosition.reserved; + browser.sessions.setWindowValue( + TabsStore.getCurrentWindowId(), + Constants.kWINDOW_STATE_SCROLL_POSITION, + mNormalScrollBox.$scrollTop + ).catch(ApiTabs.createErrorSuppressor()); + }, 150); +} + +const mReservedScrolls = new WeakMap(); + +function reserveToScrollToItem(item, options = {}) { + if (!item) + return; + + const scrollBox = getScrollBoxFor(item); + const reservedScroll = { + itemId: item.id, + options, + }; + mReservedScrolls.set(scrollBox, reservedScroll); + window.requestAnimationFrame(() => { + if (mReservedScrolls.get(scrollBox) != reservedScroll) + return; + mReservedScrolls.delete(scrollBox); + const options = reservedScroll.options; + delete reservedScroll.itemId; + delete reservedScroll.options; + scrollToItem(item, options); + }); +} + +function reserveToScrollToNewTab(item) { + if (!item) + return; + const scrollBox = getScrollBoxFor(item); + const reservedScroll = { + itemId: item.id, + }; + mReservedScrolls.set(scrollBox, reservedScroll); + window.requestAnimationFrame(() => { + if (mReservedScrolls.get(scrollBox) != reservedScroll) + return; + mReservedScrolls.delete(scrollBox); + delete reservedScroll.itemId; + scrollToNewTab(item); + }); +} + + +function reReserveScrollingForItem(item) { + if (!item) + return false; + if (reserveToScrollToItem.reservedTabId == item.id) { + reserveToScrollToItem(item); + return true; + } + if (reserveToScrollToNewTab.reservedTabId == item.id) { + reserveToScrollToNewTab(item); + return true; + } + return false; +} + + +function onMessage(message, _sender, _respond) { + if (!message || + typeof message.type != 'string' || + message.type.indexOf('ws:') != 0) + return; + + if (message.windowId && + message.windowId != TabsStore.getCurrentWindowId()) + return; + + //log('onMessage: ', message, sender); + switch (message.type) { + case Constants.kCOMMAND_GET_RENDERED_TAB_IDS: + return Promise.resolve([...new Set([ + ...Tab.getPinnedTabs(message.windowId).map(tab => tab.id), + ...mapAndFilter(mLastRenderedVirtualScrollItemIds, id => { + const [type, rawId] = id.split(':'); + return type == TreeItem.TYPE_TAB ? parseInt(rawId) : undefined; + }), + ])]); + + case Constants.kCOMMAND_ASK_TAB_IS_IN_VIEWPORT: + return Promise.resolve(isItemInViewport(Tab.get(message.tabId), { + allowPartial: message.allowPartial, + })); + } +} + +let mLastToBeActivatedTabId = null; + +async function onBackgroundMessage(message) { + switch (message.type) { + case Constants.kCOMMAND_NOTIFY_TAB_ATTACHED_COMPLETELY: { + await Tab.waitUntilTracked([ + message.tabId, + message.parentId + ]); + const item = Tab.get(message.tabId); + const parent = Tab.get(message.parentId); + if (item && parent?.active) + reserveToScrollToNewTab(item); + }; break; + + case Constants.kCOMMAND_SCROLL_TABBAR: { + const activeTab = Tab.getActiveTab(TabsStore.getCurrentWindowId()); + const scrollBox = getScrollBoxFor(activeTab, { allowFallback: true }); + switch (String(message.by).toLowerCase()) { + case 'lineup': + smoothScrollBy(-Size.getRenderedTabHeight() * configs.scrollLines); + break; + + case 'pageup': + smoothScrollBy(-scrollBox.$offsetHeight + Size.getRenderedTabHeight()); + break; + + case 'linedown': + smoothScrollBy(Size.getRenderedTabHeight() * configs.scrollLines); + break; + + case 'pagedown': + smoothScrollBy(scrollBox.$offsetHeight - Size.getRenderedTabHeight()); + break; + + default: + switch (String(message.to).toLowerCase()) { + case 'top': + smoothScrollTo({ position: 0 }); + break; + + case 'bottom': + smoothScrollTo({ position: scrollBox.$scrollTopMax }); + break; + } + break; + } + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_CREATED: { + await Tab.waitUntilTracked(message.tabId); + if (message.maybeMoved) + await SidebarItems.waitUntilNewTabIsMoved(message.tabId); + const item = Tab.get(message.tabId); + if (!item) // it can be closed while waiting + break; + const needToWaitForTreeExpansion = ( + item.$TST.collapsedOnCreated && + !item.active && + !Tab.getActiveTab(item.windowId).pinned + ); + if (shouldApplyAnimation(true) || + needToWaitForTreeExpansion) { + wait(10).then(() => { // wait until the tab is moved by TST itself + const parent = item.$TST.parent; + if (parent?.$TST.subtreeCollapsed) // possibly collapsed by other trigger intentionally + return; + const active = item.active; + item.$TST.collapsedOnCreated = false; + const activeTab = Tab.getActiveTab(item.windowId); + CollapseExpand.setCollapsed(item, { // this is required to scroll to the tab with the "last" parameter + collapsed: false, + anchor: (active || activeTab?.$TST.canBecomeSticky) ? null : activeTab, + last: !active + }); + if (!active) + notifyOutOfViewItem(item); + }); + } + else { + reserveToScrollToNewTab(item); + } + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_ACTIVATED: { + if (tryLockScrollToSuccessor.tabId == message.tabId) { + log('tryLockScrollToSuccessor: wait until unlocked for ', message.tabId); + mLastToBeActivatedTabId = message.tabId; + const canContinueToScroll = await tryLockScrollToSuccessor.promisedUnlocked; + if (!canContinueToScroll || + mLastToBeActivatedTabId != message.tabId) { + mLastToBeActivatedTabId = null; + break; + } + log('tryLockScrollToSuccessor: unlocked, scroll to ', message.tabId); + } + unlockScrollToSuccessor(false); + mLastToBeActivatedTabId = null; + await Tab.waitUntilTracked(message.tabId); + const item = Tab.get(message.tabId); + if (!item) + break; + const allowed = await TSTAPI.tryOperationAllowed( + TSTAPI.kNOTIFY_TRY_SCROLL_TO_ACTIVATED_TAB, + { tab: item }, + { tabProperties: ['tab'] } + ); + if (allowed) + reserveToScrollToItem(item); + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_UNPINNED: + await Tab.waitUntilTracked(message.tabId); + reserveToScrollToItem(Tab.get(message.tabId)); + break; + + case Constants.kCOMMAND_BROADCAST_TAB_STATE: { + if (!message.tabIds.length || + message.tabIds.length > 1 || + !message.add || + !message.add.includes(Constants.kTAB_STATE_BUNDLED_ACTIVE)) + break; + await Tab.waitUntilTracked(message.tabIds); + const item = Tab.get(message.tabIds[0]); + if (!item || + item.active) + break; + const bundled = message.add.includes(Constants.kTAB_STATE_BUNDLED_ACTIVE); + if (bundled && + (!configs.scrollToExpandedTree || + !configs.syncActiveStateToBundledTabs)) + break; + const activeTab = bundled ? + item.$TST.bundledTab : // bundled-active state may be applied before the bundled tab become active + Tab.getActiveTab(item.windowId); + if (!activeTab) + break; + reserveToScrollToItem(item, { + anchor: !activeTab.pinned && isItemInViewport(activeTab) && activeTab, + notifyOnOutOfView: true + }); + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_MOVED: + case Constants.kCOMMAND_NOTIFY_TAB_INTERNALLY_MOVED: { + await Tab.waitUntilTracked(message.tabId); + const item = Tab.get(message.tabId); + if (!item) // it can be closed while waiting + break; + if (!reReserveScrollingForItem(item) && + item.active) + reserveToScrollToItem(item); + }; break; + } +} + +function onMessageExternal(message, _aSender) { + switch (message.type) { + case TSTAPI.kSCROLL: + return (async () => { + const params = {}; + const currentWindow = TabsStore.getCurrentWindowId(); + if ('tabId' in message || 'tab' in message) { + await Tab.waitUntilTracked(message.tabId || message.tab); + params.item = Tab.get(message.tabId || message.tab); + if (!params.item || params.item.windowId != currentWindow) + return; + } + else if ('groupId' in message || 'group' in message) { + params.item = TabGroup.get(message.gorupId || message.group); + if (!params.item || params.item.windowId != currentWindow) + return; + } + else { + const windowId = message.window || message.windowId; + if (windowId == 'active') { + const currentWindow = await browser.windows.get(TabsStore.getCurrentWindowId()); + if (!currentWindow.focused) + return; + } + else if (windowId != currentWindow) { + return; + } + if ('delta' in message) { + params.delta = message.delta; + if (typeof params.delta == 'string') + params.delta = Size.calc(params.delta); + } + if ('position' in message) { + params.position = message.position; + if (typeof params.position == 'string') + params.position = Size.calc(params.position); + } + if ('duration' in message && typeof message.duration == 'number') + params.duration = message.duration; + } + return scrollTo(params).then(() => { + return true; + }); + })(); + + case TSTAPI.kSTOP_SCROLL: + return (async () => { + const currentWindow = TabsStore.getCurrentWindowId(); + const windowId = message.window || message.windowId; + if (windowId == 'active') { + const currentWindow = await browser.windows.get(TabsStore.getCurrentWindowId()); + if (!currentWindow.focused) + return; + } + else if (windowId != currentWindow) { + return; + } + cancelRunningScroll(); + return true; + })(); + } +} + +CollapseExpand.onUpdating.addListener((item, options) => { + if (!configs.scrollToExpandedTree) + return; + if (!item.pinned) + reserveToRenderVirtualScrollViewport({ trigger: 'collapseExpand' }); + if (options.last) + scrollToItem(item, { + anchor: options.anchor, + notifyOnOutOfView: true + }); +}); + +CollapseExpand.onUpdated.addListener((item, options) => { + if (!configs.scrollToExpandedTree) + return; + if (!item.pinned) + reserveToRenderVirtualScrollViewport({ trigger: 'collapseExpand' }); + if (options.last) + scrollToItem(item, { + anchor: options.anchor, + notifyOnOutOfView: true + }); + else if (item.active && !options.collapsed) + scrollToItem(item); +}); + + +// Simulate "lock tab sizing while closing tabs via mouse click" behavior of Firefox itself +// https://github.com/piroor/treestyletab/issues/2691 +// https://searchfox.org/mozilla-central/rev/27932d4e6ebd2f4b8519865dad864c72176e4e3b/browser/base/content/tabbrowser-tabs.js#1207 +export async function tryLockPosition(tabIds, reason) { + if ((!configs.simulateLockTabSizing && + !configs.deferScrollingToOutOfViewportSuccessor) || + tabIds.every(id => { + const tab = Tab.get(id); + return !tab || tab.pinned || tab.hidden; + })) { + log('tryLockPosition: ignore pinned or hidden tabs ', tabIds); + return; + } + + if (configs.deferScrollingToOutOfViewportSuccessor) + await tryLockScrollToSuccessor(tabIds, reason); + + if (configs.simulateLockTabSizing) + trySimulateLockTabSizing(tabIds, reason); + + if (!tryFinishPositionLocking.listening) { + tryFinishPositionLocking.listening = true; + window.addEventListener('mousemove', tryFinishPositionLocking); + window.addEventListener('mouseout', tryFinishPositionLocking); + } +} +tryLockPosition.tabIds = new Set(); + +async function tryLockScrollToSuccessor(tabIds, reason) { + if (reason != LOCK_REASON_REMOVE) + return; + + // We need to get tabs via WE API here to see its successorTabId certainly. + const tabs = await Promise.all(tabIds.map(id => browser.tabs.get(id))); + for (const tab of tabs) { + if (!tab.active || + !tab.successorTabId || + tab.successorTabId == tab.id) + continue; + + const successor = Tab.get(tab.successorTabId); + if (!successor || + isItemInViewport(successor)) + return; + + log('tryLockScrollToSuccessor successor = ', tab.successorTabId); + unlockScrollToSuccessor(false); + // The successor tab is out of screen, so the tab bar will be scrolled. + // We need to defer the scroll after unlocked. + tryLockScrollToSuccessor.tabId = tab.successorTabId; + tryLockScrollToSuccessor.promisedUnlocked = new Promise((resolve, _reject) => { + tryLockScrollToSuccessor.onUnlocked.add(resolve); + }); + return; + } +} +tryLockScrollToSuccessor.tabId = null; +tryLockScrollToSuccessor.promisedUnlocked = Promise.resolve(true); +tryLockScrollToSuccessor.onUnlocked = new Set(); + +function trySimulateLockTabSizing(tabIds, reason) { + // Don't lock scroll position when the last tab is closed. + const lastTab = Tab.getLastVisibleTab(); + if (reason == LOCK_REASON_REMOVE && + tabIds.includes(lastTab.id)) { + if (tryLockPosition.tabIds.size > 0) { + // but we need to add tabs to the list of "close with locked scroll position" + // tabs to prevent unexpected unlocking. + for (const id of tabIds) { + tryLockPosition.tabIds.add(id); + } + } + log('trySimulateLockTabSizing: ignore last tab remove ', tabIds); + return; + } + + // Lock scroll position only when the closing affects to the max scroll position. + if (mNormalScrollBox.$scrollTop < mNormalScrollBox.$scrollTopMax - Size.getRenderedTabHeight() - mTabbarSpacerSize) { + log('trySimulateLockTabSizing: scroll position is not affected ', tabIds, { + scrollTop: mNormalScrollBox.$scrollTop, + scrollTopMax: mNormalScrollBox.$scrollTopMax, + height: Size.getRenderedTabHeight(), + }); + return; + } + + for (const id of tabIds) { + tryLockPosition.tabIds.add(id); + } + + log('trySimulateLockTabSizing: ', tabIds); + const spacer = mNormalScrollBox.querySelector(`.${Constants.kTABBAR_SPACER}`); + const count = tryLockPosition.tabIds.size; + const height = Size.getRenderedTabHeight() * count; + spacer.style.minHeight = `${height}px`; + spacer.dataset.removedOrCollapsedTabsCount = count; + mTabbarSpacerSize = height; +} + +function unlockScrollToSuccessor(canContinueToScroll) { + tryLockScrollToSuccessor.tabId = null; + for (const callback of tryLockScrollToSuccessor.onUnlocked) { + try { + callback(canContinueToScroll); + } + catch (_error) { + } + } + tryLockScrollToSuccessor.onUnlocked.clear(); +} + +export function tryUnlockPosition(tabIds) { + if ((!configs.simulateLockTabSizing && + !configs.deferScrollingToOutOfViewportSuccessor) || + tabIds.every(id => { + const tab = Tab.get(id); + return !tab || tab.pinned || tab.hidden; + })) + return; + + unlockScrollToSuccessor(true); + + if (configs.simulateLockTabSizing) { + for (const id of tabIds) { + tryLockPosition.tabIds.delete(id); + } + + log('tryUnlockPosition/simulateLockTabSizing'); + const spacer = mNormalScrollBox.querySelector(`.${Constants.kTABBAR_SPACER}`); + const count = tryLockPosition.tabIds.size; + const timeout = shouldApplyAnimation() ? + Math.max(0, configs.collapseDuration) + 250 /* safety margin to wait finishing of the min-height animation of virtual-scroll-container */ : + 0; + setTimeout(() => { + const height = Size.getRenderedTabHeight() * count; + spacer.style.minHeight = `${height}px`; + spacer.dataset.removedOrCollapsedTabsCount = count; + mTabbarSpacerSize = height; + }, timeout); + } +} + +function tryFinishPositionLocking(event) { + log('tryFinishPositionLocking ', tryLockPosition.tabIds, event); + + switch (event?.type) { + case 'mouseout': + const relatedTarget = event.relatedTarget; + if (relatedTarget?.ownerDocument == document) { + log(' => ignore mouseout in the tabbar window itself'); + return; + } + + case 'mousemove': + if (tryFinishPositionLocking.contextMenuOpen || + (event.type == 'mousemove' && + EventUtils.getElementTarget(event)?.closest('#tabContextMenu'))) { + log(' => ignore events while the context menu is opened'); + return; + } + if (event.type == 'mousemove' && + EventUtils.getElementTarget(event).closest('#tabbar, .after-tabs, #subpanel-container')) { + log(' => ignore mousemove on the tab bar'); + return; + } + break; + + default: + break; + } + + window.removeEventListener('mousemove', tryFinishPositionLocking); + window.removeEventListener('mouseout', tryFinishPositionLocking); + tryFinishPositionLocking.listening = false; + + unlockScrollToSuccessor(true); + + tryLockPosition.tabIds.clear(); + const spacer = mNormalScrollBox.querySelector(`.${Constants.kTABBAR_SPACER}`); + spacer.dataset.removedOrCollapsedTabsCount = 0; + spacer.style.minHeight = ''; + mTabbarSpacerSize = 0; + onPositionUnlocked.dispatch(); +} +tryFinishPositionLocking.contextMenuOpen = false; + +browser.menus.onShown.addListener((info, tab) => { + tryFinishPositionLocking.contextMenuOpen = info.contexts.includes('tab') && (tab.windowId == TabsStore.getCurrentWindowId()); +}); + +browser.menus.onHidden.addListener((_info, _tab) => { + tryFinishPositionLocking.contextMenuOpen = false; +}); + +browser.tabs.onCreated.addListener(_tab => { + tryFinishPositionLocking('on tab created'); +}); + +browser.tabs.onRemoved.addListener(tabId => { + if (tryLockPosition.tabIds.has(tabId) || + Tab.get(tabId)?.$TST.collapsed) + return; + if (tryLockScrollToSuccessor.tabId) { + log(`tryLockScrollToSuccessor ignore tab remove ${tabId}`); + return; + } + tryFinishPositionLocking(`on tab removed ${tabId}`); +}); diff --git a/waterfox/browser/components/sidebar/sidebar/sidebar-items.js b/waterfox/browser/components/sidebar/sidebar/sidebar-items.js new file mode 100644 index 000000000000..0262c9159214 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/sidebar-items.js @@ -0,0 +1,1433 @@ +/* +# 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'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; + +import { + log as internalLogger, + wait, + configs, + shouldApplyAnimation, + mapAndFilter, + nextFrame, +} from '/common/common.js'; + +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from '/common/constants.js'; +import * as TabsInternalOperation from '/common/tabs-internal-operation.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TabsUpdate from '/common/tabs-update.js'; +import * as TSTAPI from '/common/tst-api.js'; + +import { Tab, TabGroup, TreeItem } from '/common/TreeItem.js'; +import Window from '/common/Window.js'; + +import * as BackgroundConnection from './background-connection.js'; +import * as CollapseExpand from './collapse-expand.js'; + +import { + kTREE_ITEM_ELEMENT_NAME, + kTREE_ITEM_SUBSTANCE_ELEMENT_NAME, + TabInvalidationTarget, + TabUpdateTarget, +} from './components/TreeItemElement.js'; + +function log(...args) { + internalLogger('sidebar/sidebar-items', ...args); +} + +let mPromisedInitializedResolver; +let mPromisedInitialized = new Promise((resolve, _reject) => { + mPromisedInitializedResolver = resolve; +}); + +export const pinnedContainer = document.querySelector('#pinned-tabs-container'); +export const normalContainer = document.querySelector('#normal-tabs-container'); + +export const onPinnedTabsChanged = new EventListenerManager(); +export const onNormalTabsChanged = new EventListenerManager(); +export const onTabsRendered = new EventListenerManager(); +export const onTabsUnrendered = new EventListenerManager(); +export const onSyncFailed = new EventListenerManager(); +export const onReuseTreeItemElement = new EventListenerManager(); + +export function init() { + document.querySelector('#sync-throbber').addEventListener('animationiteration', synchronizeThrobberAnimation); + + document.documentElement.setAttribute(Constants.kLABEL_OVERFLOW, configs.labelOverflowStyle); + + mPromisedInitializedResolver(); + mPromisedInitialized = mPromisedInitializedResolver = null; +} + +function getItemContainerElement(item) { + return document.querySelector(`.tabs.${item.pinned ? 'pinned' : 'normal'}`); +} + +export function getItemFromDOMNode(node, options = {}) { + if (typeof options != 'object') + options = {}; + if (!node) + return null; + if (!(node instanceof Element)) + node = node.parentNode; + const itemSubstance = node?.closest(kTREE_ITEM_SUBSTANCE_ELEMENT_NAME); + const item = itemSubstance?.closest(kTREE_ITEM_ELEMENT_NAME); + const raw = item?.apiRaw; + if (options.force || + raw?.type == TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER) { + return raw; + } + return TabsStore.ensureLivingItem(raw); +} + + +export async function reserveToUpdateLoadingState() { + if (mPromisedInitialized) + await mPromisedInitialized; + if (reserveToUpdateLoadingState.waiting) + clearTimeout(reserveToUpdateLoadingState.waiting); + reserveToUpdateLoadingState.waiting = setTimeout(() => { + delete reserveToUpdateLoadingState.waiting; + updateLoadingState(); + }, 0); +} + +function updateLoadingState() { + const classList = document.documentElement.classList; + const windowId = TabsStore.getCurrentWindowId(); + classList.toggle(Constants.kTABBAR_STATE_HAVE_LOADING_TAB, Tab.hasLoadingTab(windowId)); + classList.toggle(Constants.kTABBAR_STATE_HAVE_UNSYNCHRONIZED_THROBBER, Tab.hasNeedToBeSynchronizedTab(windowId)); +} + +async function synchronizeThrobberAnimation() { + let processedCount = 0; + for (const tab of Tab.getNeedToBeSynchronizedTabs(TabsStore.getCurrentWindowId(), { iterator: true })) { + tab.$TST.removeState(Constants.kTAB_STATE_THROBBER_UNSYNCHRONIZED); + TabsStore.removeUnsynchronizedTab(tab); + processedCount++; + } + if (processedCount == 0) + return; + + const classList = document.documentElement.classList; + classList.remove(Constants.kTABBAR_STATE_HAVE_UNSYNCHRONIZED_THROBBER); + + classList.add(Constants.kTABBAR_STATE_THROBBER_SYNCHRONIZING); + void document.documentElement.offsetWidth; // ensure to apply updated appearance + classList.remove(Constants.kTABBAR_STATE_THROBBER_SYNCHRONIZING); +} + + + +export function updateAll() { + updateLoadingState(); + synchronizeThrobberAnimation(); + // We need to update from bottom to top, because + // TabUpdateTarget.DescendantsHighlighted refers results of descendants. + for (const tab of Tab.getAllTabs(TabsStore.getCurrentWindowId(), { iterator: true, reverse: true })) { + tab.$TST.invalidateElement(TabInvalidationTarget.Twisty | TabInvalidationTarget.CloseBox | TabInvalidationTarget.Tooltip); + tab.$TST.updateElement(TabUpdateTarget.Counter | TabUpdateTarget.DescendantsHighlighted); + if (!tab.$TST.collapsed) + tab.$TST.element?.updateOverflow(); + } +} + + + +export function reserveToSyncTabsOrder() { + if (configs.delayToRetrySyncTabsOrder <= 0) { + syncTabsOrder(); + return; + } + if (reserveToSyncTabsOrder.timer) + clearTimeout(reserveToSyncTabsOrder.timer); + reserveToSyncTabsOrder.timer = setTimeout(() => { + delete reserveToSyncTabsOrder.timer; + syncTabsOrder(); + }, configs.delayToRetrySyncTabsOrder); +} +reserveToSyncTabsOrder.retryCount = 0; + +async function syncTabsOrder() { + log('syncTabsOrder'); + const windowId = TabsStore.getCurrentWindowId(); + const [internalOrder, nativeOrder] = await Promise.all([ + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_PULL_TABS_ORDER, + windowId + }).catch(ApiTabs.createErrorHandler()), + browser.tabs.query({ windowId }).then(tabs => tabs.map(tab => tab.id)) + ]); + + const trackedWindow = TabsStore.windows.get(windowId); + const actualOrder = trackedWindow.order; + + log('syncTabsOrder: ', { internalOrder, nativeOrder, actualOrder }); + + if (internalOrder.join('\n') == actualOrder.join('\n') && + internalOrder.join('\n') == nativeOrder.join('\n')) { + reserveToSyncTabsOrder.retryCount = 0; + return; // no need to sync + } + + const expectedTabs = internalOrder.slice(0).sort().join('\n'); + const nativeTabs = nativeOrder.slice(0).sort().join('\n'); + if (expectedTabs != nativeTabs) { + if (reserveToSyncTabsOrder.retryCount > 10) { + console.error(new Error(`Fatal error: native tabs are not same to the tabs tracked by the background process, for the window ${windowId}. Reloading all...`)); + reserveToSyncTabsOrder.retryCount = 0; + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_RELOAD, + all: true + }).catch(ApiTabs.createErrorSuppressor()); + return; + } + log(`syncTabsOrder: retry / Native tabs are not same to the tabs tracked by the background process, but this can happen when synchronization and tab removing are done in parallel. Retry count = ${reserveToSyncTabsOrder.retryCount}`); + reserveToSyncTabsOrder.retryCount++; + return reserveToSyncTabsOrder(); + } + + const actualTabs = actualOrder.slice(0).sort().join('\n'); + if (expectedTabs != actualTabs) { + log(`syncTabsOrder: retry / Native tabs are not same to the tabs tracked by the background process, but this can happen on synchronization and tab removing are. Retry count = ${reserveToSyncTabsOrder.retryCount}`); + if (reserveToSyncTabsOrder.retryCount > 10) { + console.error(new Error(`Error: tracked tabs are not same to pulled tabs, for the window ${windowId}. Rebuilding...`)); + reserveToSyncTabsOrder.retryCount = 0; + return onSyncFailed.dispatch(); + } + log(`syncTabsOrder: retry / Native tabs are not same to tab elements, but this can happen on synchronization and tab removing are. Retry count = ${reserveToSyncTabsOrder.retryCount}`); + reserveToSyncTabsOrder.retryCount++; + return reserveToSyncTabsOrder(); + } + + reserveToSyncTabsOrder.retryCount = 0; + trackedWindow.order = internalOrder; + let count = 0; + for (const tab of trackedWindow.getOrderedTabs()) { + tab.index = count++; + tab.reindexedBy = `syncTabsOrder (${tab.index})`; + tab.$TST.invalidateCache(); + } +} + +function getItemElementId(item) { + return `${item.$TST.type}-${item.id}`; +} + +const mRenderedItemIds = new Set(); +const mUnrenderedItemIds = new Set(); +let mItemElementsPool = []; + +export function renderItem(item, { containerElement, insertBefore } = {}) { + if (!item) { + console.log('WARNING: Null item has requested to be rendered! ', new Error().stack); + return false; + } + if (!item.$TST) { + console.log('WARNING: Alerady destroyed item has requested to be rendered! ', item.id, new Error().stack); + return false; + } + + let created = false; + if (!item.$TST.element || + !item.$TST.element.parentNode) { + const reuseFromPool = (mItemElementsPool.length > 0); + const itemElement = reuseFromPool ? + mItemElementsPool.pop() : + document.createElement(kTREE_ITEM_ELEMENT_NAME); + item.$TST.bindElement(itemElement); + item.$TST.setAttribute('id', getItemElementId(item)); + item.$TST.setAttribute('type', item.$TST.type); + item.$TST.setAttribute(Constants.kAPI_WINDOW_ID, item.windowId || -1); + switch (item.type) { + case TreeItem.TYPE_GROUP: + item.$TST.setAttribute(Constants.kAPI_NATIVE_TAB_GROUP_ID, item.id || -1); + item.$TST.removeAttribute(Constants.kGROUP_ID); + break; + + case TreeItem.TYPE_GROUP_COLLAPSED_MEMBERS_COUNTER: + item.$TST.setAttribute(Constants.kAPI_NATIVE_TAB_GROUP_ID, item.id || -1); + item.$TST.setAttribute(Constants.kGROUP_ID, item.id); + break; + + default: + item.$TST.setAttribute(Constants.kAPI_TAB_ID, item.id || -1); + item.$TST.setAttribute(Constants.kGROUP_ID, item.groupId); + item.$TST.addState(Constants.kTAB_STATE_THROBBER_UNSYNCHRONIZED); + TabsStore.addUnsynchronizedTab(item); + break; + } + if (reuseFromPool) { + itemElement.favIconUrl = null; + onReuseTreeItemElement.dispatch(itemElement); + } + created = true; + } + + const win = TabsStore.windows.get(item.windowId); + const itemElement = item.$TST.element; + containerElement = containerElement || ( + item.pinned ? + win.pinnedContainerElement : + win.containerElement + ); + + if (item.pinned) { + // Pinned tabs are always rendered physically, so we use the next + // same type item as the reference. + insertBefore = item.$TST.nearestSameTypeRenderedTab; + } + let nextElement = insertBefore?.nodeType == Node.ELEMENT_NODE ? + insertBefore : + insertBefore?.$TST.element; + if (insertBefore && + nextElement === undefined && + (containerElement == win.containerElement || + containerElement == win.pinnedContainerElement)) { + const nextTab = item.$TST.nearestSameTypeRenderedTab; + log(`render item element for ${item.id} (pinned=${item.pinned}) before ${nextTab?.id} (originally ${insertBefore?.id}), item, nextTab, insertBefore = `, item, nextTab, insertBefore); + nextElement = nextTab?.$TST.element.parentNode == containerElement ? + nextTab.$TST.element : + null; + } + + if (itemElement.parentNode == containerElement && + itemElement.nextSibling == nextElement) + return false; + + containerElement.insertBefore(itemElement, nextElement); + + if (created) { + if (!item.active && item.$TST.states.has(Constants.kTAB_STATE_ACTIVE)) { + console.log('WARNING: Inactive item has invalid "active" state! ', item.id) + item.$TST.removeState(Constants.kTAB_STATE_ACTIVE); + } + + item.$TST.invalidateElement(TabInvalidationTarget.Twisty | TabInvalidationTarget.CloseBox | TabInvalidationTarget.Tooltip | TabInvalidationTarget.Overflow); + item.$TST.updateElement(TabUpdateTarget.Counter | TabUpdateTarget.Overflow | TabUpdateTarget.TabProperties); + item.$TST.applyStatesToElement(); + + // To apply animation effect, we need to set and remove + // the "collapsed" state again. + if (shouldApplyAnimation() && + item.$TST.states.has(Constants.kTAB_STATE_EXPANDING) && + !item.$TST.states.has(Constants.kTAB_STATE_COLLAPSED)) { + itemElement.classList.remove(Constants.kTAB_STATE_ANIMATION_READY); + itemElement.classList.add(Constants.kTAB_STATE_COLLAPSED); + window.requestAnimationFrame(() => { + itemElement.classList.add(Constants.kTAB_STATE_ANIMATION_READY); + itemElement.classList.remove(Constants.kTAB_STATE_COLLAPSED); + }); + } + else { + itemElement.classList.add(Constants.kTAB_STATE_ANIMATION_READY); + } + + mRenderedItemIds.add(item.id); + mUnrenderedItemIds.delete(item.id); + reserveToNotifyItemsRendered(); + } + + return true; +} + +function reserveToNotifyItemsRendered() { + const hasInternalListener = onTabsRendered.hasListener(); + const hasExternalListener = TSTAPI.hasListenerForMessageType(TSTAPI.kNOTIFY_TABS_RENDERED); + if (!hasInternalListener && !hasExternalListener) { + mRenderedItemIds.clear(); + return; + } + + if (reserveToNotifyItemsRendered.invoked) + return; + reserveToNotifyItemsRendered.invoked = true; + window.requestAnimationFrame(() => { + reserveToNotifyItemsRendered.invoked = false; + + const ids = [...mRenderedItemIds]; + mRenderedItemIds.clear(); + const tabs = mapAndFilter(ids, id => Tab.get(id)); + if (tabs.length == 0) { + return; + } + + if (hasInternalListener) + onTabsRendered.dispatch(tabs); + + if (hasExternalListener) { + let cache = {}; + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_TABS_RENDERED, + tabs, + }, { tabProperties: ['tabs'], cache }).catch(_error => {}); + cache = null; + } + }); +} + +let mClearPoolTimer = null; + +export function unrenderItem(item) { + if (!item?.$TST?.element) + return false; + + mRenderedItemIds.delete(item.id); + mUnrenderedItemIds.add(item.id); + + const itemElement = item.$TST.element; + + if (item.type == TreeItem.TYPE_TAB) { + item.$TST.removeState(Constants.kTAB_STATE_THROBBER_UNSYNCHRONIZED); + TabsStore.removeUnsynchronizedTab(item); + } + + const hasInternalListener = onTabsUnrendered.hasListener(); + const hasExternalListener = TSTAPI.hasListenerForMessageType(TSTAPI.kNOTIFY_TABS_UNRENDERED); + if (hasInternalListener || hasExternalListener) { + if (!unrenderItem.invoked) { + unrenderItem.invoked = true; + window.requestAnimationFrame(() => { + unrenderItem.invoked = false; + + const ids = [...mUnrenderedItemIds]; + mUnrenderedItemIds.clear(); + const tabs = mapAndFilter(ids, id => Tab.get(id)); + + if (hasInternalListener) + onTabsUnrendered.dispatch(tabs); + + if (hasExternalListener) { + let cache = {}; + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_TABS_UNRENDERED, + tabs, + }, { tabProperties: ['tabs'], cache }).catch(_error => {}); + cache = null; + } + }); + } + } + else { + mUnrenderedItemIds.clear(); + } + + if (!itemElement || + !itemElement.parentNode) + return false; + + itemElement.parentNode.removeChild(itemElement); + item.$TST.unbindElement(); + + // We reuse already generated elements for better performance. + // See also: https://github.com/piroor/treestyletab/issues/3477 + mItemElementsPool.push(itemElement); + if (mClearPoolTimer) + clearTimeout(mClearPoolTimer); + mClearPoolTimer = setTimeout(() => { + mItemElementsPool = []; + }, configs.generatedTreeItemElementsPoolLifetimeMsec); + + return true; +} + +Window.onInitialized.addListener(win => { + const windowId = win.id; + win = TabsStore.windows.get(windowId); + + let innerPinnedContainer = document.getElementById(`window-${windowId}-pinned`); + if (!innerPinnedContainer) { + innerPinnedContainer = document.createElement('ul'); + pinnedContainer.appendChild(innerPinnedContainer); + } + innerPinnedContainer.dataset.windowId = windowId; + innerPinnedContainer.setAttribute('id', `window-${windowId}-pinned`); + innerPinnedContainer.classList.add('tabs'); + innerPinnedContainer.classList.add('pinned'); + innerPinnedContainer.setAttribute('role', 'listbox'); + innerPinnedContainer.setAttribute('aria-multiselectable', 'true'); + innerPinnedContainer.$TST = win; + win.bindPinnedContainerElement(innerPinnedContainer); + + let innerNormalContainer = document.getElementById(`window-${windowId}`); + if (!innerNormalContainer) { + innerNormalContainer = document.querySelector('#normal-tabs-container .virtual-scroll-container').appendChild(document.createElement('ul')); + } + innerNormalContainer.dataset.windowId = windowId; + innerNormalContainer.setAttribute('id', `window-${windowId}`); + innerNormalContainer.classList.add('tabs'); + innerNormalContainer.classList.add('normal'); + innerNormalContainer.setAttribute('role', 'listbox'); + innerNormalContainer.setAttribute('aria-multiselectable', 'true'); + innerNormalContainer.$TST = win; + win.bindContainerElement(innerNormalContainer); +}); + + +let mReservedUpdateActiveTab; + +configs.$addObserver(async changedKey => { + switch (changedKey) { + case 'labelOverflowStyle': + document.documentElement.setAttribute(Constants.kLABEL_OVERFLOW, configs.labelOverflowStyle); + break; + + case 'renderHiddenTabs': { + let hasNormalTab = false; + for (const tab of Tab.getHiddenTabs(TabsStore.getCurrentWindowId(), { iterator: true })) { + TabsStore.updateVirtualScrollRenderabilityIndexForTab(tab); + if (!tab.pinned) + hasNormalTab = true; + } + if (hasNormalTab) + onNormalTabsChanged.dispatch(); + }; break; + } +}); + + +// Mechanism to override "index" of newly opened tabs by TST's detection logic + +const mMovedNewTabResolvers = new Map(); +const mPromsiedMovedNewTabs = new Map(); +const mAlreadyMovedNewTabs = new Set(); + +export async function waitUntilNewTabIsMoved(tabId) { + if (mAlreadyMovedNewTabs.has(tabId)) + return true; + if (mPromsiedMovedNewTabs.has(tabId)) + return mPromsiedMovedNewTabs.get(tabId); + const timer = setTimeout(() => { + if (mMovedNewTabResolvers.has(tabId)) + mMovedNewTabResolvers.get(tabId)(); + }, Math.max(0, configs.tabBunchesDetectionTimeout)); + const promise = new Promise((resolve, _reject) => { + mMovedNewTabResolvers.set(tabId, resolve); + }).then(newIndex => { + mMovedNewTabResolvers.delete(tabId); + mPromsiedMovedNewTabs.delete(tabId); + clearTimeout(timer); + return newIndex; + }); + mPromsiedMovedNewTabs.set(tabId, promise); + return promise; +} + +function maybeNewTabIsMoved(tabId) { + if (mMovedNewTabResolvers.has(tabId)) { + mMovedNewTabResolvers.get(tabId)(); + } + else { + mAlreadyMovedNewTabs.add(tabId); + setTimeout(() => { + mAlreadyMovedNewTabs.delete(tabId); + }, Math.min(10 * 1000, configs.tabBunchesDetectionTimeout)); + } +} + + +const mPendingUpdates = new Map(); + +function setupPendingUpdate(update) { + const pendingUpdate = mPendingUpdates.get(update.tabId) || { tabId: update.tabId }; + + update.addedStates = new Set(update.addedStates || []); + update.removedStates = new Set(update.removedStates || []); + update.removedAttributes = new Set(update.removedAttributes || []); + update.addedAttributes = update.addedAttributes || {}; + update.updatedProperties = update.updatedProperties || {}; + + pendingUpdate.updatedProperties = { + ...(pendingUpdate.updatedProperties || {}), + ...update.updatedProperties + }; + + if (update.removedAttributes.size > 0) { + pendingUpdate.removedAttributes = new Set([...(pendingUpdate.removedAttributes || []), ...update.removedAttributes]); + if (pendingUpdate.addedAttributes) + for (const attribute of update.removedAttributes) { + delete pendingUpdate.addedAttributes[attribute]; + } + } + + if (Object.keys(update.addedAttributes).length > 0) { + pendingUpdate.addedAttributes = { + ...(pendingUpdate.addedAttributes || {}), + ...update.addedAttributes + }; + if (pendingUpdate.removedAttributes) + for (const attribute of Object.keys(update.removedAttributes)) { + pendingUpdate.removedAttributes.delete(attribute); + } + } + + if (update.removedStates.size > 0) { + pendingUpdate.removedStates = new Set([...(pendingUpdate.removedStates || []), ...update.removedStates]); + if (pendingUpdate.addedStates) + for (const state of update.removedStates) { + pendingUpdate.addedStates.delete(state); + } + } + + if (update.addedStates.size > 0) { + pendingUpdate.addedStates = new Set([...(pendingUpdate.addedStates || []), ...update.addedStates]); + if (pendingUpdate.removedStates) + for (const state of update.addedStates) { + pendingUpdate.removedStates.delete(state); + } + } + + pendingUpdate.soundStateChanged = pendingUpdate.soundStateChanged || update.soundStateChanged; + + mPendingUpdates.set(update.tabId, pendingUpdate); +} + +function tryApplyUpdate(update) { + const tab = Tab.get(update.tabId); + if (!tab) + return; + + const highlightedChanged = update.updatedProperties && 'highlighted' in update.updatedProperties; + + if (update.updatedProperties) { + const oldGroupId = tab.groupId; + for (const [key, value] of Object.entries(update.updatedProperties)) { + if (Tab.UNSYNCHRONIZABLE_PROPERTIES.has(key)) + continue; + tab[key] = value; + } + + if ('groupId' in update.updatedProperties) { + tab.$TST.onNativeGroupModified(oldGroupId); + tab.$TST.updateElement(TabUpdateTarget.TabProperties); + } + } + + if (update.addedAttributes) { + for (const key of Object.keys(update.addedAttributes)) { + tab.$TST.setAttribute(key, update.addedAttributes[key]); + } + } + + if (update.removedAttributes) { + for (const key of update.removedAttributes) { + tab.$TST.removeAttribute(key, ); + } + } + + if (update.soundStateChanged) { + const parent = tab.$TST.parent; + if (parent) + parent.$TST.inheritSoundStateFromChildren(); + } + + if (update.sharingStateChanged) { + const parent = tab.$TST.parent; + if (parent) + parent.$TST.inheritSharingStateFromChildren(); + } + + tab.$TST.invalidateElement(TabInvalidationTarget.SoundButton | TabInvalidationTarget.Tooltip); + + if (highlightedChanged) { + tab.$TST.invalidateElement(TabInvalidationTarget.CloseBox); + for (const ancestor of tab.$TST.ancestors) { + ancestor.$TST.updateElement(TabUpdateTarget.DescendantsHighlighted); + } + if (mReservedUpdateActiveTab) + clearTimeout(mReservedUpdateActiveTab); + mReservedUpdateActiveTab = setTimeout(() => { + mReservedUpdateActiveTab = null; + const activeTab = Tab.getActiveTab(tab.windowId); + activeTab.$TST.invalidateElement(TabInvalidationTarget.SoundButton | TabInvalidationTarget.CloseBox); + }, 50); + } +} + +async function activateRealActiveTab(windowId) { + const tabs = await browser.tabs.query({ active: true, windowId }); + if (tabs.length <= 0) + throw new Error(`FATAL ERROR: No active tab in the window ${windowId}`); + const id = tabs[0].id; + await Tab.waitUntilTracked(id); + const tab = Tab.get(id); + if (!tab) + throw new Error(`FATAL ERROR: Active tab ${id} in the window ${windowId} is not tracked`); + TabsInternalOperation.setTabActive(tab); +} + +Tab.onActivated.addListener(tab => { + getItemContainerElement(tab)?.setAttribute('aria-activedescendant', getItemElementId(tab)); + if (tab.groupId != -1) { + reserveToRefreshNativeTabGroup(tab.groupId); + } +}); + +Tab.onUnactivated.addListener(tab => { + getItemContainerElement(tab)?.removeAttribute('aria-activedescendant'); + if (tab.groupId != -1) { + reserveToRefreshNativeTabGroup(tab.groupId); + } +}); + +const mReindexedTabIds = new Set(); + +function reserveToUpdateTabsIndex() { + if (reserveToUpdateTabsIndex.invoked) + return; + reserveToUpdateTabsIndex.invoked = true; + window.requestAnimationFrame(() => { + reserveToUpdateTabsIndex.invoked = false; + + const ids = [...mReindexedTabIds]; + mReindexedTabIds.clear(); + for (const id of ids) { + const tab = Tab.get(id); + if (!tab) + continue; + tab.$TST.applyAttributesToElement(); + } + }); +} + +function reserveToRefreshNativeTabGroup(id) { + if (reserveToRefreshNativeTabGroup.invoked.has(id)) + return; + reserveToRefreshNativeTabGroup.invoked.add(id); + window.requestAnimationFrame(() => { + reserveToRefreshNativeTabGroup.invoked.delete(id); + + const group = TabGroup.get(id); + if (!group) { + return; + } + group.$TST.updateElement(TabUpdateTarget.TabProperties); + for (const tab of group.$TST.members) { + CollapseExpand.setCollapsed(tab, { + collapsed: tab.$TST.collapsedByParent, + }); + tab.$TST.updateElement(TabUpdateTarget.TabProperties); + } + }); +} +reserveToRefreshNativeTabGroup.invoked = new Set(); + +Tab.onNativeGroupModified.addListener(async tab => { + CollapseExpand.setCollapsed(tab, { + collapsed: await tab.$TST.promisedCollapsedByParent, + }); + tab.$TST.updateElement(TabUpdateTarget.TabProperties); +}); + + +const BUFFER_KEY_PREFIX = 'sidebar-tab-'; + +const mRemovedTabIdsNotifiedBeforeTracked = new Set(); +const mWaitingTasksOnSameTick = new Map(); + +BackgroundConnection.onMessage.addListener(async message => { + switch (message.type) { + case Constants.kCOMMAND_SYNC_TABS_ORDER: + reserveToSyncTabsOrder(); + break; + + case Constants.kCOMMAND_BROADCAST_TAB_STATE: { + if (!message.tabIds.length) + break; + await Tab.waitUntilTracked(message.tabIds); + const add = message.add || []; + const remove = message.remove || []; + log('apply broadcasted tab state ', message.tabIds, { + add: add.join(','), + remove: remove.join(',') + }); + const modified = new Set([...add, ...remove]); + let stickyStateChanged = false; + for (const id of message.tabIds) { + const tab = Tab.get(id); + if (!tab) + continue; + for (const state of add) { + tab.$TST.addState(state, { toTab: true }); + } + for (const state of remove) { + tab.$TST.removeState(state, { toTab: true }); + } + if (modified.has(Constants.kTAB_STATE_AUDIBLE) || + modified.has(Constants.kTAB_STATE_SOUND_PLAYING) || + modified.has(Constants.kTAB_STATE_MUTED) || + modified.has(Constants.kTAB_STATE_AUTOPLAY_BLOCKED)) { + tab.$TST.invalidateElement(TabInvalidationTarget.SoundButton); + } + if (modified.has(Constants.kTAB_STATE_STICKY)) + stickyStateChanged = true; + } + if (stickyStateChanged || + [...TreeItem.autoStickyStates.values()].some(states => (new Set([...states, ...modified])).size < states.size + modified.size)) + onNormalTabsChanged.dispatch(); + }; break; + + case Constants.kCOMMAND_BROADCAST_TAB_TOOLTIP_TEXT: { + if (!message.tabIds.length) + break; + await Tab.waitUntilTracked(message.tabIds); + log('apply broadcasted tab tooltip text ', message.changes); + for (const change of message.changes) { + const tab = Tab.get(change.tabId); + if (!tab) + continue; + tab.$TST.registerTooltipText(browser.runtime.id, change.high, true); + tab.$TST.registerTooltipText(browser.runtime.id, change.low, false); + tab.$TST.invalidateElement(TabInvalidationTarget.Tooltip); + } + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_CREATING: { + const nativeTab = message.tab; + nativeTab.reindexedBy = `creating (${nativeTab.index})`; + + if (mRemovedTabIdsNotifiedBeforeTracked.has(nativeTab.id)) { + log(`ignore kCOMMAND_NOTIFY_TAB_CREATING for already closed tab: ${nativeTab.id}`); + return; + } + + // The "index" property of the tab was already updated by the background process + // with other newly opened tabs. However, such other tabs are not tracked on + // this sidebar namespace yet. Thus we need to correct the index of the tab + // to be inserted to already tracked tabs. + // For example: + // - tabs in the background page: [a,b,X,Y,Z,c,d] + // - tabs in the sidebar page: [a,b,c,d] + // - notified tab: Z (as index=4) (X and Y will be notified later) + // then the new tab Z must be treated as index=2 and the result must become + // [a,b,Z,c,d] instead of [a,b,c,d,Z]. How should we calculate the index with + // less amount? + const win = TabsStore.windows.get(message.windowId); + let index = 0; + for (const id of message.order) { + if (win.tabs.has(id)) { + nativeTab.index = ++index; + nativeTab.reindexedBy = `creating/fixed (${nativeTab.index})`; + } + if (id == message.tabId) + break; + } + + const tab = Tab.init(nativeTab, { inBackground: true }); + TabsUpdate.updateTab(tab, tab, { forceApply: true }); + + for (const tab of Tab.getAllTabs(message.windowId, { fromId: nativeTab.id })) { + mReindexedTabIds.add(tab.id); + } + reserveToUpdateTabsIndex(); + + TabsStore.addLoadingTab(tab); + TabsStore.updateVirtualScrollRenderabilityIndexForTab(tab); + if (shouldApplyAnimation()) { + CollapseExpand.setCollapsed(tab, { + collapsed: true, + justNow: true + }); + tab.$TST.collapsedOnCreated = true; + } + else { + reserveToUpdateLoadingState(); + } + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_CREATED: { + if (message.active) { + BackgroundConnection.handleBufferedMessage({ + type: Constants.kCOMMAND_NOTIFY_TAB_ACTIVATED, + tabId: message.tabId, + windowId: message.windowId + }, `${BUFFER_KEY_PREFIX}window-${message.windowId}`); + } + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + if (!tab) { + log(`ignore kCOMMAND_NOTIFY_TAB_CREATED for already closed tab: ${message.tabId}`); + return; + } + tab.$TST.removeState(Constants.kTAB_STATE_ANIMATION_READY); + tab.$TST.resolveOpened(); + if (message.maybeMoved) + await waitUntilNewTabIsMoved(message.tabId); + if (tab.pinned) { + renderItem(tab); + onPinnedTabsChanged.dispatch(tab); + } + else { + onNormalTabsChanged.dispatch(tab); + } + reserveToUpdateLoadingState(); + const needToWaitForTreeExpansion = ( + tab.$TST.shouldExpandLater && + !tab.active && + !Tab.getActiveTab(tab.windowId).pinned + ); + if (shouldApplyAnimation(true) || + needToWaitForTreeExpansion) { + wait(10).then(() => { // wait until the tab is moved by TST itself + // On this case we don't need to expand the tab here, because + // it will be expanded by scroll.js's kCOMMAND_NOTIFY_TAB_CREATED handler + // for scrolling to a newly opened tab via CollapseExpand.setCollapsed(). + reserveToUpdateLoadingState(); + }); + } + if (tab.active) { + if (shouldApplyAnimation()) { + await wait(0); // nextFrame() is too fast! + if (!message.collapsed /* the new tab may be really collapsed not just for animation, and we should not expand */ && + tab.$TST.collapsedOnCreated) { + CollapseExpand.setCollapsed(tab, { + collapsed: false, + }); + reserveToUpdateLoadingState(); + } + } + const lastMessage = BackgroundConnection.fetchBufferedMessage(Constants.kCOMMAND_NOTIFY_TAB_ACTIVATED, `${BUFFER_KEY_PREFIX}window-${message.windowId}`); + if (!lastMessage) + break; + await Tab.waitUntilTracked(lastMessage.tabId); + const activeTab = Tab.get(lastMessage.tabId); + TabsInternalOperation.setTabActive(activeTab); + } + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_RESTORED: { + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + if (!tab) + return; + tab.$TST.addState(Constants.kTAB_STATE_RESTORED); + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_ACTIVATED: { + if (BackgroundConnection.handleBufferedMessage(message, `${BUFFER_KEY_PREFIX}window-${message.windowId}`)) + return; + await Tab.waitUntilTracked(message.tabId); + const lastMessage = BackgroundConnection.fetchBufferedMessage(message.type, `${BUFFER_KEY_PREFIX}window-${message.windowId}`); + if (!lastMessage) + return; + const tab = Tab.get(lastMessage.tabId); + if (!tab) + return; + TabsInternalOperation.setTabActive(tab); + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_UPDATED: { + // We don't use BackgroundConnection.handleBufferedMessage/BackgroundConnection.fetchBufferedMessage for this type message because update type messages need to be merged more intelligently. + const hasPendingUpdate = mPendingUpdates.has(message.tabId); + + // Updates may be notified before the tab element is actually created, + // so we should apply updates ASAP. We can update already tracked tab + // while "creating" is notified and waiting for "created". + // See also: https://github.com/piroor/treestyletab/issues/2275 + tryApplyUpdate(message); + setupPendingUpdate(message); + + // Already pending update will be processed later, so we don't need + // process this update. + if (hasPendingUpdate) + return; + + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + if (!tab) + return; + + const update = mPendingUpdates.get(message.tabId) || message; + mPendingUpdates.delete(update.tabId); + + tryApplyUpdate(update); + + TabsStore.updateIndexesForTab(tab); + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_MOVED: { + // Tab move messages are notified as an array at a time, + // but Tab.waitUntilTracked() may break their order. + // So we do a hack to wait messages as a group received at a time. + maybeNewTabIsMoved(message.tabId); + const promises = mWaitingTasksOnSameTick.get(message.type) || []; + promises.push(Tab.waitUntilTracked([message.tabId, message.nextTabId])); + mWaitingTasksOnSameTick.set(message.type, promises); + await nextFrame(); + mWaitingTasksOnSameTick.delete(message.type); + await Promise.all(promises); + + const tab = Tab.get(message.tabId); + if (!tab || + tab.index == message.toIndex) + return; + if (mPromisedInitialized) + await mPromisedInitialized; + if (tab.$TST.parent) + tab.$TST.parent.$TST.invalidateElement(TabInvalidationTarget.Tooltip); + + tab.$TST.addState(Constants.kTAB_STATE_MOVING); + + let shouldAnimate = false; + if (shouldApplyAnimation() && + !tab.pinned && + !tab.$TST.opening && + !tab.$TST.collapsed) { + shouldAnimate = true; + CollapseExpand.setCollapsed(tab, { + collapsed: true, + justNow: true + }); + tab.$TST.shouldExpandLater = true; + } + + tab.index = message.toIndex; + tab.reindexedBy = `moved (${tab.index})`; + const win = TabsStore.windows.get(message.windowId); + win.trackTab(tab); + + for (const tab of Tab.getAllTabs(message.windowId, { + fromIndex: Math.min(message.fromIndex, message.toIndex), + toIndex: Math.max(message.fromIndex, message.toIndex), + iterator: true, + })) { + mReindexedTabIds.add(tab.id); + } + reserveToUpdateTabsIndex(); + + if (tab.pinned) { + renderItem(tab); + onPinnedTabsChanged.dispatch(tab); + } + else { + onNormalTabsChanged.dispatch(tab); + } + tab.$TST.applyAttributesToElement(); + + if (shouldAnimate && tab.$TST.shouldExpandLater) { + CollapseExpand.setCollapsed(tab, { + collapsed: false + }); + await wait(configs.collapseDuration); + } + tab.$TST.removeState(Constants.kTAB_STATE_MOVING); + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_INTERNALLY_MOVED: { + // Tab move also should be stabilized with BackgroundConnection.handleBufferedMessage/BackgroundConnection.fetchBufferedMessage but the buffering mechanism is not designed for messages which need to be applied sequentially... + maybeNewTabIsMoved(message.tabId); + await Tab.waitUntilTracked([message.tabId, message.nextTabId]); + const tab = Tab.get(message.tabId); + if (!tab || + tab.index == message.toIndex) + return; + tab.index = message.toIndex; + tab.reindexedBy = `internally moved (${tab.index})`; + Tab.track(tab); + + for (const tab of Tab.getAllTabs(message.windowId, { + fromIndex: Math.min(message.fromIndex, message.toIndex), + toIndex: Math.max(message.fromIndex, message.toIndex), + iterator: true, + })) { + mReindexedTabIds.add(tab.id); + } + reserveToUpdateTabsIndex(); + + if (tab.pinned) { + renderItem(tab); + onPinnedTabsChanged.dispatch(tab); + } + else { + onNormalTabsChanged.dispatch(tab); + } + tab.$TST.applyAttributesToElement(); + if (!message.broadcasted) { + // Tab element movement triggered by sidebar itself can break order of + // tabs synchronized from the background, so for safetyl we trigger + // synchronization. + reserveToSyncTabsOrder(); + } + }; break; + + case Constants.kCOMMAND_UPDATE_LOADING_STATE: { + if (BackgroundConnection.handleBufferedMessage(message, `${BUFFER_KEY_PREFIX}${message.tabId}`)) + return; + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + const lastMessage = BackgroundConnection.fetchBufferedMessage(message.type, `${BUFFER_KEY_PREFIX}${message.tabId}`); + if (tab && + lastMessage) { + if (lastMessage.status == 'loading') { + tab.$TST.removeState(Constants.kTAB_STATE_BURSTING); + TabsStore.addLoadingTab(tab); + tab.$TST.addState(Constants.kTAB_STATE_THROBBER_UNSYNCHRONIZED); + TabsStore.addUnsynchronizedTab(tab); + } + else { + if (lastMessage.reallyChanged) { + tab.$TST.addState(Constants.kTAB_STATE_BURSTING); + if (tab.$TST.delayedBurstEnd) + clearTimeout(tab.$TST.delayedBurstEnd); + tab.$TST.delayedBurstEnd = setTimeout(() => { + delete tab.$TST.delayedBurstEnd; + tab.$TST.removeState(Constants.kTAB_STATE_BURSTING); + if (!tab.active) + tab.$TST.addState(Constants.kTAB_STATE_NOT_ACTIVATED_SINCE_LOAD); + }, configs.burstDuration); + } + tab.$TST.removeState(Constants.kTAB_STATE_THROBBER_UNSYNCHRONIZED); + TabsStore.removeUnsynchronizedTab(tab); + TabsStore.removeLoadingTab(tab); + } + } + reserveToUpdateLoadingState(); + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_REMOVING: { + const tab = Tab.get(message.tabId); + if (!tab) { + log(`ignore kCOMMAND_NOTIFY_TAB_REMOVING for already closed tab: ${message.tabId}`); + mRemovedTabIdsNotifiedBeforeTracked.add(message.tabId); + wait(10000).then(() => { + mRemovedTabIdsNotifiedBeforeTracked.delete(message.tabId); + }); + return; + } + tab.$TST.parent = null; + // remove from "highlighted tabs" cache immediately, to prevent misdetection for "multiple highlighted". + TabsStore.removeHighlightedTab(tab); + TabsStore.removeGroupTab(tab); + TabsStore.addRemovingTab(tab); + TabsStore.addRemovedTab(tab); // reserved + TabsStore.updateVirtualScrollRenderabilityIndexForTab(tab); + reserveToUpdateLoadingState(); + if (tab.active) { + // This should not, but sometimes happens on some edge cases for example: + // https://github.com/piroor/treestyletab/issues/2385 + activateRealActiveTab(message.windowId); + } + if (!tab.$TST.collapsed && + shouldApplyAnimation()) { + tab.$TST.addState(Constants.kTAB_STATE_REMOVING); // addState()'s result from the background page may not be notified yet, so we set this state manually here + CollapseExpand.setCollapsed(tab, { + collapsed: true + }); + if (tab.pinned) + onPinnedTabsChanged.dispatch(tab); + else + onNormalTabsChanged.dispatch(tab); + } + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_REMOVED: { + const tab = Tab.get(message.tabId); + // Don't untrack tab here because we need to keep it rendered for removing animation. + //TabsStore.windows.get(message.windowId).detachTab(message.tabId); + if (!tab) { + log(`ignore kCOMMAND_NOTIFY_TAB_REMOVED for already closed tab: ${message.tabId}`); + return; + } + if (tab.active) { + // This should not, but sometimes happens on some edge cases for example: + // https://github.com/piroor/treestyletab/issues/2385 + activateRealActiveTab(message.windowId); + } + if (shouldApplyAnimation()) + await wait(configs.collapseDuration); + TabsStore.windows.get(message.windowId).detachTab(message.tabId); + tab.$TST.destroy(); + unrenderItem(tab); + if (tab.pinned) + onPinnedTabsChanged.dispatch(tab); + else + onNormalTabsChanged.dispatch(tab); + }; break; + + case Constants.kCOMMAND_NOTIFY_TREE_ITEM_LABEL_UPDATED: { + if (BackgroundConnection.handleBufferedMessage(message, `${BUFFER_KEY_PREFIX}${message.tabId}`)) + return; + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + const lastMessage = BackgroundConnection.fetchBufferedMessage(message.type, `${BUFFER_KEY_PREFIX}${message.tabId}`); + if (!tab || + !lastMessage) + return; + tab.title = lastMessage.title; + tab.$TST.label = lastMessage.label; + if (tab.$TST.element) + tab.$TST.element.label = lastMessage.label; + tab.$TST.invalidateCache(); + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_FAVICON_UPDATED: { + if (BackgroundConnection.handleBufferedMessage(message, `${BUFFER_KEY_PREFIX}${message.tabId}`)) + return; + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + const lastMessage = BackgroundConnection.fetchBufferedMessage(message.type, `${BUFFER_KEY_PREFIX}${message.tabId}`); + if (!tab || + !lastMessage) + return; + tab.favIconUrl = lastMessage.favIconUrl; + tab.$TST.favIconUrl = lastMessage.favIconUrl; + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_SOUND_STATE_UPDATED: { + if (BackgroundConnection.handleBufferedMessage(message, `${BUFFER_KEY_PREFIX}${message.tabId}`)) + return; + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + const lastMessage = BackgroundConnection.fetchBufferedMessage(message.type, `${BUFFER_KEY_PREFIX}${message.tabId}`); + if (!tab || + !lastMessage) + return; + tab.$TST.toggleState(Constants.kTAB_STATE_HAS_SOUND_PLAYING_MEMBER, lastMessage.hasSoundPlayingMember); + tab.$TST.toggleState(Constants.kTAB_STATE_HAS_MUTED_MEMBER, lastMessage.hasMutedMember); + tab.$TST.toggleState(Constants.kTAB_STATE_HAS_AUTOPLAY_BLOCKED_MEMBER, lastMessage.hasAutoplayBlockedMember); + tab.$TST.invalidateElement(TabInvalidationTarget.SoundButton); + }; break; + + case Constants.kCOMMAND_NOTIFY_HIGHLIGHTED_TABS_CHANGED: { + BackgroundConnection.handleBufferedMessage(message, `${BUFFER_KEY_PREFIX}window-${message.windowId}`); + await Tab.waitUntilTracked(message.tabIds); + const lastMessage = BackgroundConnection.fetchBufferedMessage(message.type, `${BUFFER_KEY_PREFIX}window-${message.windowId}`); + if (!lastMessage || + lastMessage.tabIds.join(',') != message.tabIds.join(',')) + return; + TabsUpdate.updateTabsHighlighted(message); + const win = TabsStore.windows.get(message.windowId); + if (!win || !win.containerElement) + return; + document.documentElement.classList.toggle(Constants.kTABBAR_STATE_MULTIPLE_HIGHLIGHTED, message.tabIds.length > 1); + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_PINNED: + case Constants.kCOMMAND_NOTIFY_TAB_UNPINNED: { + if (BackgroundConnection.handleBufferedMessage({ type: 'pinned/unpinned', message }, `${BUFFER_KEY_PREFIX}${message.tabId}`)) + return; + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + const lastMessage = BackgroundConnection.fetchBufferedMessage('pinned/unpinned', `${BUFFER_KEY_PREFIX}${message.tabId}`); + if (!tab || + !lastMessage) + return; + tab.$TST.invalidateCache(); + if (lastMessage.message.type == Constants.kCOMMAND_NOTIFY_TAB_PINNED) { + tab.pinned = true; + TabsStore.removeUnpinnedTab(tab); + TabsStore.addPinnedTab(tab); + renderItem(tab); + } + else { + tab.pinned = false; + TabsStore.removePinnedTab(tab); + TabsStore.addUnpinnedTab(tab); + unrenderItem(tab); + } + TabsStore.updateVirtualScrollRenderabilityIndexForTab(tab); + onPinnedTabsChanged.dispatch(tab.pinned && tab); + onNormalTabsChanged.dispatch(!tab.pinned && tab); + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_HIDDEN: + case Constants.kCOMMAND_NOTIFY_TAB_SHOWN: { + if (BackgroundConnection.handleBufferedMessage({ type: 'shown/hidden', message }, `${BUFFER_KEY_PREFIX}${message.tabId}`)) + return; + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + const lastMessage = BackgroundConnection.fetchBufferedMessage('shown/hidden', `${BUFFER_KEY_PREFIX}${message.tabId}`); + if (!tab || + !lastMessage) + return; + tab.$TST.invalidateCache(); + if (lastMessage.message.type == Constants.kCOMMAND_NOTIFY_TAB_HIDDEN) { + tab.hidden = true; + TabsStore.removeVisibleTab(tab); + TabsStore.removeControllableTab(tab); + } + else { + tab.hidden = false; + if (!tab.$TST.collapsed) + TabsStore.addVisibleTab(tab); + TabsStore.addControllableTab(tab); + } + TabsStore.updateVirtualScrollRenderabilityIndexForTab(tab); + if (tab.pinned) + onPinnedTabsChanged.dispatch(tab); + else + onNormalTabsChanged.dispatch(tab); + }; break; + + case Constants.kCOMMAND_NOTIFY_SUBTREE_COLLAPSED_STATE_CHANGING: { + if (BackgroundConnection.handleBufferedMessage(message, `${BUFFER_KEY_PREFIX}${message.tabId}`)) + return; + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + const lastMessage = BackgroundConnection.fetchBufferedMessage(message.type, `${BUFFER_KEY_PREFIX}${message.tabId}`); + if (!tab || + !lastMessage) + return; + const changingDescendants = tab.$TST.descendants.filter(descendant => descendant.collapsed != message.collapsed); + if (message.collapsed) { + for (const tab of changingDescendants) { + TabsStore.removeScrollPositionCalculationTargetTab(tab); + } + } + else { + for (const tab of changingDescendants) { + TabsStore.addScrollPositionCalculationTargetTab(tab); + } + } + }; break; + + case Constants.kCOMMAND_NOTIFY_SUBTREE_COLLAPSED_STATE_CHANGED: { + if (BackgroundConnection.handleBufferedMessage(message, `${BUFFER_KEY_PREFIX}${message.tabId}`)) + return; + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + const lastMessage = BackgroundConnection.fetchBufferedMessage(message.type, `${BUFFER_KEY_PREFIX}${message.tabId}`); + if (!tab || + !lastMessage) + return; + tab.$TST.invalidateCache(); + tab.$TST.invalidateElement(TabInvalidationTarget.CloseBox); + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_COLLAPSED_STATE_CHANGED: { + if (BackgroundConnection.handleBufferedMessage(message, `${BUFFER_KEY_PREFIX}${message.tabId}`) || + message.collapsed) + return; + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + const lastMessage = BackgroundConnection.fetchBufferedMessage(message.type, `${BUFFER_KEY_PREFIX}${message.tabId}`); + if (!tab || + !lastMessage || + lastMessage.collapsed) + return; + TabsStore.addVisibleTab(tab); + TabsStore.addExpandedTab(tab); + reserveToUpdateLoadingState(); + tab.$TST.invalidateCache(); + tab.$TST.invalidateElement(TabInvalidationTarget.Twisty | TabInvalidationTarget.CloseBox | TabInvalidationTarget.Tooltip); + tab.$TST.element?.updateOverflow(); + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_ATTACHED_TO_WINDOW: { + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + if (!tab) + return; + tab.$TST.invalidateCache(); + if (tab.active) + TabsInternalOperation.setTabActive(tab); // to clear "active" state of other tabs + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_DETACHED_FROM_WINDOW: { + // don't wait until tracked here, because detaching tab will become untracked! + const tab = Tab.get(message.tabId); + if (!tab) + return; + tab.$TST.invalidateElement(TabInvalidationTarget.Tooltip); + tab.$TST.parent = null; + TabsStore.addRemovedTab(tab); + const win = TabsStore.windows.get(message.windowId); + win.untrackTab(message.tabId); + unrenderItem(tab); + if (tab.pinned) + onPinnedTabsChanged.dispatch(tab); + else + onNormalTabsChanged.dispatch(tab); + // Allow to move tabs to this window again, after a timeout. + // https://github.com/piroor/treestyletab/issues/2316 + wait(500).then(() => TabsStore.removeRemovedTab(tab)); + }; break; + + case Constants.kCOMMAND_NOTIFY_GROUP_TAB_DETECTED: { + await Tab.waitUntilTracked(message.tabId); + const tab = Tab.get(message.tabId); + if (!tab) + return; + // When a group tab is restored but pending, TST cannot update title of the tab itself. + // For failsafe now we update the title based on its URL. + const url = new URL(tab.url); + let title = url.searchParams.get('title'); + if (!title) { + const parameters = tab.url.replace(/^[^\?]+/, ''); + title = parameters.match(/^\?([^&;]*)/); + title = title && decodeURIComponent(title[1]); + } + title = title || browser.i18n.getMessage('groupTab_label_default'); + tab.title = title; + wait(0).then(() => { + TabsUpdate.updateTab(tab, { title }); + }); + }; break; + + case Constants.kCOMMAND_NOTIFY_CHILDREN_CHANGED: { + if (mPromisedInitialized) + return; + // We need to wait not only for added children but removed children also, + // to construct same number of promises for "attached but detached immediately" + // cases. + const relatedTabIds = [message.tabId].concat(message.addedChildIds, message.removedChildIds); + await Tab.waitUntilTracked(relatedTabIds); + const tab = Tab.get(message.tabId); + if (!tab) + return; + + if (message.addedChildIds.length > 0) { + // set initial level for newly opened child, to avoid annoying jumping of new tab + const childLevel = parseInt(tab.$TST.getAttribute(Constants.kLEVEL) || 0) + 1; + for (const childId of message.addedChildIds) { + const child = Tab.get(childId); + if (!child || child.$TST.hasChild) + continue; + const currentLevel = child.$TST.getAttribute(Constants.kLEVEL) || 0; + if (currentLevel == 0) + child.$TST.setAttribute(Constants.kLEVEL, childLevel); + } + } + + tab.$TST.children = message.childIds; + + tab.$TST.invalidateElement(TabInvalidationTarget.Twisty | TabInvalidationTarget.CloseBox | TabInvalidationTarget.Tooltip); + if (message.newlyAttached || message.detached) { + const ancestors = [tab].concat(tab.$TST.ancestors); + for (const ancestor of ancestors) { + ancestor.$TST.updateElement(TabUpdateTarget.Counter | TabUpdateTarget.DescendantsHighlighted); + } + } + }; break; + + case Constants.kCOMMAND_BROADCAST_TAB_AUTO_STICKY_STATE: + if (message.add) + TreeItem.registerAutoStickyState(message.providerId, message.add); + if (message.remove) + TreeItem.unregisterAutoStickyState(message.providerId, message.remove); + onNormalTabsChanged.dispatch(); + break; + + case Constants.kCOMMAND_NOTIFY_TAB_GROUP_CREATED: + TabGroup.init(message.group) + break; + + case Constants.kCOMMAND_NOTIFY_TAB_GROUP_UPDATED: { + const group = TabGroup.get(message.group.id); + if (!group) { + return; + } + group.$TST.apply(message.group); + reserveToRefreshNativeTabGroup(message.group.id); + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_GROUP_REMOVED: + TabGroup.get(message.group.id)?.$TST.destroy(); + break; + } +}); diff --git a/waterfox/browser/components/sidebar/sidebar/sidebar.css b/waterfox/browser/components/sidebar/sidebar/sidebar.css new file mode 100644 index 000000000000..373dee3e7133 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/sidebar.css @@ -0,0 +1,16 @@ +@charset "UTF-8"; +/* +# 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 url("styles/base.css"); +@import url("styles/twisty.css"); +@import url("styles/favicon.css"); +@import url("styles/throbber.css"); +@import url("styles/sound-button.css"); +@import url("styles/sharing-state.css"); +@import url("styles/drag-and-drop.css"); +@import url("styles/highlighter.css"); + diff --git a/waterfox/browser/components/sidebar/sidebar/sidebar.html b/waterfox/browser/components/sidebar/sidebar/sidebar.html new file mode 100644 index 000000000000..152a5f3626ec --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/sidebar.html @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + + + + + +
              +
              +
              +
              +
              +
              +
              +
              +
              +
              +
              +
              +
              +
              +
              +
              + + +
              +
              +
              +
              +
              +
              +
              + + +
              +
              +
              +
              +
              +
              +
              + + + + + + + +
              +
              +
                +
                  +
                • __MSG_tabbar_newTabAction_independent_label__
                • +
                • __MSG_tabbar_newTabAction_child_label__
                • +
                • __MSG_tabbar_newTabAction_sibling_label__
                • +
                • __MSG_tabbar_newTabAction_nextSibling_label__
                • +
                +
                  +
                  +
                  +
                  +
                  +
                  +
                  +
                    + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + + + + + + 0 + + + + + + + + +
                  +
                  + + +
                  + +
                  +
                    +
                      +
                      + + diff --git a/waterfox/browser/components/sidebar/sidebar/sidebar.js b/waterfox/browser/components/sidebar/sidebar/sidebar.js new file mode 100644 index 000000000000..5627bd3aa4d5 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/sidebar.js @@ -0,0 +1,1215 @@ +/* +# 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'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; +import RichConfirm from '/extlib/RichConfirm.js'; + +import { + log as internalLogger, + nextFrame, + mapAndFilter, + configs, + shouldApplyAnimation, + loadUserStyleRules, + isMacOS, + isRTL, + notify, + waitUntilStartupOperationsUnblocked, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Bookmark from '/common/bookmark.js'; +import * as BrowserTheme from '/common/browser-theme.js'; +import * as Color from '/common/color.js'; +import * as Constants from '/common/constants.js'; +import * as ContextualIdentities from '/common/contextual-identities.js'; +import * as CssSelectorParser from '/common/css-selector-parser.js'; +import * as TabsInternalOperation from '/common/tabs-internal-operation.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TabsUpdate from '/common/tabs-update.js'; +import * as TSTAPI from '/common/tst-api.js'; +import * as UserOperationBlocker from '/common/user-operation-blocker.js'; + +import MetricsData from '/common/MetricsData.js'; +import { Tab, TabGroup } from '/common/TreeItem.js'; +import Window from '/common/Window.js'; + +import * as BackgroundConnection from './background-connection.js'; +import * as CollapseExpand from './collapse-expand.js'; +import * as DragAndDrop from './drag-and-drop.js'; +import * as EventUtils from './event-utils.js'; +import * as GapCanceller from './gap-canceller.js'; +import * as Indent from './indent.js'; +import * as Notifications from './notifications.js'; +import * as PinnedTabs from './pinned-tabs.js'; +import * as RestoringTabCount from './restoring-tab-count.js'; +import * as Scroll from './scroll.js'; +import * as SidebarItems from './sidebar-items.js'; +import * as Size from './size.js'; +import * as SubPanel from './subpanel.js'; +import * as TabContextMenu from './tab-context-menu.js'; + +import { TabCloseBoxElement } from './components/TabCloseBoxElement.js'; +import { TabCounterElement } from './components/TabCounterElement.js'; +import { + TreeItemElement, + TabInvalidationTarget, +} from './components/TreeItemElement.js'; +import { TabFaviconElement } from './components/TabFaviconElement.js'; +import { TreeItemLabelElement } from './components/TreeItemLabelElement.js'; +import { TabSoundButtonElement } from './components/TabSoundButtonElement.js'; +import { TabTwistyElement } from './components/TabTwistyElement.js'; + +function log(...args) { + internalLogger('sidebar/sidebar', ...args); +} + +export const onInit = new EventListenerManager(); +export const onBuilt = new EventListenerManager(); +export const onReady = new EventListenerManager(); +export const onLayoutUpdated = new EventListenerManager(); + + +let mTargetWindow = null; +let mConnectionOpenCount = 0; +let mInitialized = false; +let mReloadMaskImage = false; // workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=1763420 + +let mPromisedTargetWindowResolver; +const mPromisedTargetWindow = new Promise((resolve, _reject) => { + mPromisedTargetWindowResolver = resolve; +}); + +const mTabBar = document.querySelector('#tabbar'); +const mStyleLoader = document.querySelector('#style-loader'); +const mBrowserThemeDefinition = document.querySelector('#browser-theme-definition'); +const mUserStyleRules = document.querySelector('#user-style-rules'); +const mContextualIdentitiesStyle = document.querySelector('#contextual-identity-styling'); + +// allow customiation for platform specific styles with selectors like `:root[data-user-agent*="Windows NT 10"]` +document.documentElement.dataset.userAgent = navigator.userAgent; +document.documentElement.classList.toggle('platform-mac', isMacOS()); +document.documentElement.classList.toggle('rtl', isRTL()); + +{ + const url = new URL(location.href); + + mTargetWindow = parseInt(url.searchParams.get('windowId') || 0); + if (isNaN(mTargetWindow) || mTargetWindow < 1) + mTargetWindow = null; + + EventUtils.setTargetWindowId(mTargetWindow); + mTabBar.dataset.windowId = mTargetWindow; + + mReloadMaskImage = url.searchParams.get('reloadMaskImage') == 'true'; + + // apply style ASAP! + const style = url.searchParams.get('style'); + applyTheme({ style }); + + const title = url.searchParams.get('title'); + if (title) + document.title = title; +} + +applyAnimationState(shouldApplyAnimation()); +UserOperationBlocker.block({ throbber: true }); + +export async function init() { + MetricsData.add('init: start'); + log('initialize sidebar on load'); + + // If we call `window.customElements.define(localName, constructor)`;` from a file defining a custom element, + // it would be a side-effect and happen accidentally that defining a custom element + // when we import a new file which defines a new custom element. + // It causes a complex side-effect relations and usually causes a bug. It's tough to fix. + // + // I have not concluded the best practice about it yet, + // but I think that it's safely to call `window.customElements.define(localName, constructor)` separately + // in the application initialization phase. + // + // XXX: + // We define our custom elements at first to avoid a problem which calls a method of custom element + // which has not been defined yet. + TabTwistyElement.define(); + TabCloseBoxElement.define(); + TabFaviconElement.define(); + TreeItemLabelElement.define(); + TabCounterElement.define(); + TabSoundButtonElement.define(); + TreeItemElement.define(); + + let promisedAllTabsTracked; + UserOperationBlocker.setProgress(0); + await Promise.all([ + MetricsData.addAsync('getting native tabs', async () => { + const win = await MetricsData.addAsync( + 'getting window', + mTargetWindow ? + browser.windows.get(mTargetWindow, { populate: true }) : + browser.windows.getCurrent({ populate: true }) + ).catch(ApiTabs.createErrorHandler()); + if (win.focused) + document.documentElement.classList.add('active'); + const trackedWindow = TabsStore.windows.get(win.id) || new Window(win.id); + trackedWindow.incognito = win.incognito; + if (win.incognito) + document.documentElement.classList.add('incognito'); + + const tabs = win.tabs; + if (!mTargetWindow) { + mTargetWindow = tabs[0].windowId; + EventUtils.setTargetWindowId(mTargetWindow); + } + TabsStore.setCurrentWindowId(mTargetWindow); + mTabBar.dataset.windowId = mTargetWindow; + mPromisedTargetWindowResolver(mTargetWindow); + internalLogger.context = `Sidebar-${mTargetWindow}`; + + // Track only the first tab for now, because it is required to initialize + // the container. + Tab.track(tabs[0]); + + promisedAllTabsTracked = MetricsData.addAsync('tracking all native tabs', async () => { + let lastDraw = Date.now(); + let count = 0; + const maxCount = tabs.length - 1; + for (const tab of tabs.slice(1)) { + Tab.track(tab); + if (Date.now() - lastDraw > configs.intervalToUpdateProgressForBlockedUserOperation) { + UserOperationBlocker.setProgress(Math.round(++count / maxCount * 16) + 16); // 2/6: track all tabs + await nextFrame(); + lastDraw = Date.now(); + } + } + }); + + PinnedTabs.init(); + Indent.init(); + + return tabs; + }), + configs.$loaded.then(waitUntilStartupOperationsUnblocked), + ]); + MetricsData.add('browser.tabs.query finish, configs are loaded.'); + EventListenerManager.debug = configs.debug; + + onConfigChange('colorScheme'); + onConfigChange('simulateSVGContextFill'); + onInit.dispatch(); + + const promisedScrollPosition = browser.sessions.getWindowValue(mTargetWindow, Constants.kWINDOW_STATE_SCROLL_POSITION).catch(ApiTabs.createErrorHandler()); + const promisedInitializedContextualIdentities = ContextualIdentities.init(); + + UserOperationBlocker.setProgress(16); // 1/6: wait background page + const promisedResults = Promise.all([ + MetricsData.addAsync('importWindowFromBackground()', importWindowFromBackground()), + MetricsData.addAsync('promisedAllTabsTracked', promisedAllTabsTracked) + ]); + log('Start queuing of messages from the background page'); + BackgroundConnection.connect(); + const [importedWindow] = await promisedResults; + + // we don't need await for these features + MetricsData.addAsync('API for other addons', TSTAPI.initAsFrontend()); + + await Promise.all([ + MetricsData.addAsync('parallel initialization: main', async () => { + await MetricsData.addAsync('parallel initialization: main: rebuildAll', rebuildAll(importedWindow)); + Size.init(); // this must be called after rebuildAll() + + TabsUpdate.completeLoadingTabs(mTargetWindow); + + log('Start to process messages including queued ones'); + BackgroundConnection.start(); + + configs.$addObserver(onConfigChange); + onConfigChange('debug'); + onConfigChange('sidebarPosition'); + onConfigChange('faviconizePinnedTabs'); + onConfigChange('showContextualIdentitiesSelector'); + onConfigChange('showNewTabActionSelector'); + onConfigChange('shiftTabsForScrollbarOnlyOnHover'); + onConfigChange('fadeOutPendingTabs'); + onConfigChange('fadeOutDiscardedTabs'); + + document.addEventListener('focus', onFocus); + document.addEventListener('blur', onBlur); + window.addEventListener('resize', onResize); + mTabBar.addEventListener('transitionend', onTransisionEnd); + + browser.theme.onUpdated.addListener(onBrowserThemeChanged); + + // We need to re-calculate mixed colors when the system color scheme is changed. + // See also: https://github.com/piroor/treestyletab/issues/2314 + window.matchMedia('(prefers-color-scheme: dark)').addListener(async _event => { + const theme = await browser.theme.getCurrent(mTargetWindow); + applyBrowserTheme(theme); + }); + + browser.runtime.onMessage.addListener(onMessage); + + onBuilt.dispatch(); + + DragAndDrop.init(); + }), + MetricsData.addAsync('parallel initialization: contextual identities', async () => { + await promisedInitializedContextualIdentities; + updateContextualIdentitiesStyle(); + updateContextualIdentitiesSelector(); + ContextualIdentities.startObserve(); + }), + MetricsData.addAsync('parallel initialization: TabContextMenu', async () => { + TabContextMenu.init(); + }) + ]); + + await MetricsData.addAsync('parallel initialization: post process', Promise.all([ + MetricsData.addAsync('parallel initialization: post process: main', async () => { + Indent.updateRestoredTree(); + SidebarItems.updateAll(); + updateTabbarLayout({ justNow: true }); + SubPanel.onResized.addListener(() => { + reserveToUpdateTabbarLayout(); + }); + SubPanel.init(); + + SidebarItems.init(); + Indent.tryUpdateVisualMaxTreeLevel(); + + shouldApplyAnimation.onChanged.addListener(applyAnimationState); + applyAnimationState(shouldApplyAnimation()); + + onReady.dispatch(); + }), + MetricsData.addAsync('parallel initialization: post process: Scroll.init', async () => { + Scroll.init(await promisedScrollPosition); + Scroll.onPositionUnlocked.addListener(() => { + reserveToUpdateTabbarLayout({ + reason: Constants.kTABBAR_UPDATE_REASON_TAB_CLOSE, + timeout: shouldApplyAnimation() ? configs.collapseDuration : 0 + }); + }); + Scroll.onVirtualScrollViewportUpdated.addListener(resized => { + if (!resized) + return; + updateTabbarLayout({ + reason: Constants.kTABBAR_UPDATE_REASON_VIRTUAL_SCROLL_VIEWPORT_UPDATE, + }); + }); + }) + ])); + + TabsUpdate.completeLoadingTabs(mTargetWindow); // failsafe + + // Failsafe. If the sync operation fail after retryings, + // SidebarItems.onSyncFailed is notified then this sidebar page will be + // reloaded for complete retry. + SidebarItems.onSyncFailed.addListener(() => rebuildAll()); + SidebarItems.reserveToSyncTabsOrder(); + + Size.onUpdated.addListener(() => { + updateTabbarLayout({ + reason: Constants.kTABBAR_UPDATE_REASON_RESIZE, + }); + }); + + document.documentElement.classList.remove('initializing'); + mInitialized = true; + UserOperationBlocker.unblock({ throbber: true }); + + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_SIDEBAR_SHOW, + window: mTargetWindow, + windowId: mTargetWindow, + openCount: mConnectionOpenCount, + }); + + GapCanceller.init(); + + MetricsData.add('init: end'); + if (configs.debug) + log(`Startup metrics for ${Tab.getTabs(mTargetWindow).length} tabs: `, MetricsData.toString()); +} + +function applyAnimationState(active) { + const rootClasses = document.documentElement.classList; + if (active) + rootClasses.add('animation'); + else + rootClasses.remove('animation'); +} + +async function applyTheme({ style } = {}) { + const [theme, ] = await Promise.all([ + browser.theme.getCurrent(mTargetWindow), + style && applyOwnTheme(style), + !style && configs.$loaded.then(() => applyOwnTheme()), + configs.$loaded + ]); + applyBrowserTheme(theme); + applyUserStyleRules(); + if (mReloadMaskImage) + reloadAllMaskImages(); + + Size.updateTabs(); +} + +async function applyOwnTheme(style) { + if (!style) + style = configs.style; + switch (style) { + case 'proton': + mStyleLoader.setAttribute('href', 'styles/proton/proton.css'); + break; + case 'sidebar': + mStyleLoader.setAttribute('href', 'styles/sidebar/sidebar.css'); + break; + case 'photon': + // for backward compatibility, fall back to plain. + case 'plain': + case 'flat': + case 'vertigo': + case 'mixed': + mStyleLoader.setAttribute('href', 'styles/photon/photon.css'); + break; + case 'highcontrast': + mStyleLoader.setAttribute('href', 'styles/photon/highcontrast.css'); + break; + default: + // as the base of customization. see also: + // https://github.com/piroor/treestyletab/issues/1604 + mStyleLoader.setAttribute('href', 'data:text/css,'); + break; + } + return new Promise((resolve, _reject) => { + mStyleLoader.addEventListener('load', () => { + window.requestAnimationFrame(resolve); + }, { once: true }); + }); +} + +const CSS_SPECIFICITY_INCREASER = ':not(#___NEVER___#___USED___#___ID___)'; + +function applyUserStyleRules() { + Size.clear(); + + mUserStyleRules.textContent = loadUserStyleRules(); + + // Simple selectors in user styles may have specificity lower than the one of + // built-in CSS declarations of TST itself. + // So TST adds needless selector which increase specificity of the selector. + // See also: + // https://github.com/piroor/treestyletab/issues/3153 + // https://github.com/piroor/treestyletab/issues/3163 + processAllStyleRulesIn(mUserStyleRules.sheet, rule => { + if (!rule.selectorText) + return; + + log('updating selector: ', rule.selectorText); + rule.selectorText = CssSelectorParser.splitSelectors(rule.selectorText) + .map(selector => { + const parts = CssSelectorParser.splitSelectorParts(selector); + parts[0] = CssSelectorParser.appendPart(parts[0], CSS_SPECIFICITY_INCREASER); + return parts.join(' '); + }) + .join(', '); + log(' => ', rule.selectorText); + }); + + Size.updateTabs(); +} + +function processAllStyleRulesIn(sheetOrRule, processor) { + for (const rule of sheetOrRule.cssRules) { + if (rule.styleSheet) + processAllStyleRulesIn(rule.styleSheet, processor); + else if (rule.cssRules && rule.cssRules.length > 0) // @media and so son + processAllStyleRulesIn(rule, processor); + else + processor(rule); + } +} + + +async function applyBrowserTheme(theme) { + log('applying theme ', theme); + + const browserThemeStyle = await BrowserTheme.generateThemeDeclarations(theme); + // Apply theme color at first, to use given colors as the base of following "face-*" colors. + mBrowserThemeDefinition.textContent = browserThemeStyle; + + const baseColor = Color.parseCSSColor(window.getComputedStyle(document.querySelector('#dummy-tab-color-box'), null).backgroundColor); + const highlightColor = Color.parseCSSColor(window.getComputedStyle(document.querySelector('#dummy-highlight-color-box'), null).backgroundColor); + const defaultColors = `:root { + --face-highlight-lighter: ${Color.mixCSSColors(baseColor, { ...highlightColor, alpha: 0.35 })}; + --face-highlight-more-lighter: ${Color.mixCSSColors(baseColor, { ...highlightColor, alpha: 0.2 })}; + --face-highlight-more-more-lighter: ${Color.mixCSSColors(baseColor, { ...highlightColor, alpha: 0.1 })}; + --face-gradient-start-active: rgba(${baseColor.red}, ${baseColor.green}, ${baseColor.blue}, 0.4); + --face-gradient-start-inactive: rgba(${baseColor.red}, ${baseColor.green}, ${baseColor.blue}, 0.2); + --face-gradient-end: rgba(${baseColor.red}, ${baseColor.green}, ${baseColor.blue}, 0); + }`; + + mBrowserThemeDefinition.textContent = [ + defaultColors, + browserThemeStyle + ].join('\n'); +} + +function updateContextualIdentitiesStyle() { + const colorInfo = ContextualIdentities.getColorInfo(); + const definitions = Object.keys(colorInfo.colors).map(id => + `.tab.contextual-identity-${id} .contextual-identity-marker { + background-color: ${colorInfo.colors[id]}; + }`); + + // This is required to map different color for color names. + // See also: https://github.com/piroor/treestyletab/issues/2296 + definitions.push(colorInfo.colorDeclarations); + + mContextualIdentitiesStyle.textContent = definitions.join('\n'); +} + + +// Workaround for https://github.com/piroor/treestyletab/issues/3142 +// Firefox 101 and later versions may have something edge case bug around CSS +// mask-iamge, it sometimes fails to apply masks on the initial loading. +// After I disable and re-enable a CSS rule for a mask image by the DOM inspector +// the problem looks solved. These codes simulates the operation by scanning all +// CSS rules via CSSOM automatically. +// Related bug on Fierfox side: https://bugzilla.mozilla.org/show_bug.cgi?id=1763420 + +const URL_PATTERN = /^(?:url\()?(?:'(.+)'|"(.+)")(?:\))?$/; +function reloadAllMaskImages() { + const delayedTasks = []; + for (const sheet of document.styleSheets) { + processAllStyleRulesIn(sheet, rule => { + if (!rule.style || + !rule.style.maskImage || + !URL_PATTERN.test(rule.style.maskImage)) + return; + + const background = rule.style.background; + const image = rule.style.maskImage; + + if (background) + rule.style.background = 'none'; + rule.style.maskImage = ''; + + delayedTasks.push(() => { + rule.style.maskImage = image; + if (background) + rule.style.background = background; + }); + }); + } + setTimeout(() => { + for (const task of delayedTasks) { + task(); + } + }, 0); +} + + +function updateContextualIdentitiesSelector() { + const disabled = document.documentElement.classList.contains('incognito') || ContextualIdentities.getCount() == 0; + + const anchors = document.querySelectorAll(`.${Constants.kCONTEXTUAL_IDENTITY_SELECTOR}-marker`); + for (const anchor of anchors) { + if (disabled) + anchor.setAttribute('disabled', true); + else + anchor.removeAttribute('disabled'); + } + + const selector = document.getElementById(Constants.kCONTEXTUAL_IDENTITY_SELECTOR); + const range = document.createRange(); + range.selectNodeContents(selector); + range.deleteContents(); + + if (disabled) + return; + + const fragment = ContextualIdentities.generateMenuItems({ + hasDefault: configs.inheritContextualIdentityToChildTabMode != Constants.kCONTEXTUAL_IDENTITY_DEFAULT, + }); + range.insertNode(fragment); + range.detach(); +} + +async function rebuildAll(importedWindow) { + MetricsData.add('rebuildAll: start'); + const trackedWindow = TabsStore.windows.get(mTargetWindow); + if (!trackedWindow) + Window.init(mTargetWindow); + + if (!importedWindow) + importedWindow = await MetricsData.addAsync('rebuildAll: import tabs and groups', browser.runtime.sendMessage({ + type: Constants.kCOMMAND_PING_TO_BACKGROUND, + windowId: mTargetWindow + }).catch(ApiTabs.createErrorHandler())); + + // Ignore tabs already closed. It can happen when the first tab is + // immediately reopened by other addons like Temporary Container. + const importedTabIds = new Set(importedWindow.tabs.map(tab => tab.id)); + for (const tab of Tab.getAllTabs()) { + if (!importedTabIds.has(tab.id)) + Tab.untrack(tab.id); + } + + const tabs = importedWindow.tabs.map(importedTab => Tab.import(importedTab)); + + Window.init(mTargetWindow, importedWindow.tabGroups.map(TabGroup.init)); + let lastDraw = Date.now(); + let count = 0; + const maxCount = tabs.length; + for (const tab of tabs) { + const trackedTab = Tab.init(tab, { existing: true, inBackground: true }); + const group = trackedTab.$TST.nativeTabGroup; + if (group?.collapsed) { + CollapseExpand.setCollapsed(tab, { + collapsed: true, + }); + } + TabsUpdate.updateTab(trackedTab, tab, { forceApply: true }); + if (tab.active) + TabsInternalOperation.setTabActive(trackedTab); + if (trackedTab.pinned) + SidebarItems.renderItem(trackedTab); + if (Date.now() - lastDraw > configs.intervalToUpdateProgressForBlockedUserOperation) { + UserOperationBlocker.setProgress(Math.round(++count / maxCount * 33) + 66); // 3/3: build tab elements + await nextFrame(); + lastDraw = Date.now(); + } + } + MetricsData.add('rebuildAll: end (from scratch)'); + + document.documentElement.classList.toggle(Constants.kTABBAR_STATE_MULTIPLE_HIGHLIGHTED, Tab.getHighlightedTabs(mTargetWindow).length > 1); + SidebarItems.reserveToUpdateLoadingState(); + + importedWindow = null; // wipe it out from the RAM. + return false; +} + +let mGiveUpImportWindow = false; +const mImportedWindow = new Promise((resolve, _reject) => { + log('preparing mImportedWindow'); + // This must be synchronous , to avoid blocking to other listeners. + const onBackgroundIsReady = message => { + if (mGiveUpImportWindow) { + log('mImportedWindow (${windowId}): give up to import, unregister onBackgroundIsReady listener'); + browser.runtime.onMessage.removeListener(onBackgroundIsReady); + resolve({ tabs: [], tabGroups: [] }); + return; + } + // This handler may be called before mTargetWindow is initialized, so + // we need to wait until it is resolved. + // See also: https://github.com/piroor/treestyletab/issues/2200 + mPromisedTargetWindow.then(windowId => { + if (mGiveUpImportWindow) { + log('mImportedWindow (${windowId}): give up to import, unregister onBackgroundIsReady listener (with promised target window)'); + browser.runtime.onMessage.removeListener(onBackgroundIsReady); + resolve({ tabs: [], tabGroups: [] }); + return; + } + log(`mImportedWindow (${windowId}): onBackgroundIsReady `, message?.type, message?.windowId); + if (message?.type != Constants.kCOMMAND_NOTIFY_BACKGROUND_READY || + message?.windowId != windowId) + return; + browser.runtime.onMessage.removeListener(onBackgroundIsReady); + log(`mImportedWindow is resolved with ${message.exported.tabs.length} tabs`); + resolve(message.exported); + }); + }; + browser.runtime.onMessage.addListener(onBackgroundIsReady); +}); + +async function importWindowFromBackground() { + log('importWindowFromBackground: start'); + try { + const importedWin = await MetricsData.addAsync('importWindowFromBackground: kCOMMAND_PING_TO_BACKGROUND', browser.runtime.sendMessage({ + type: Constants.kCOMMAND_PING_TO_BACKGROUND, + windowId: mTargetWindow + }).catch(ApiTabs.createErrorHandler())); + if (importedWin) { + log('importWindowFromBackground: use response of kCOMMAND_PING_TO_BACKGROUND'); + mGiveUpImportWindow = true; + return importedWin; + } + } + catch(e) { + log('importWindowFromBackground: error: ', e); + } + log('importWindowFromBackground: waiting for mImportedWindow'); + return MetricsData.addAsync('importWindowFromBackground: kCOMMAND_PING_TO_SIDEBAR', mImportedWindow); +} + + +export async function confirmToCloseTabs(tabs, { configKey } = {}) { + const tabIds = []; + if (!configKey) + configKey = 'warnOnCloseTabs'; + tabs = tabs.filter(tab => { + if (!configs.grantedRemovingTabIds.includes(tab.id)) { + tabIds.push(tab.id); + return true; + } + return false; + }); + log(`confirmToCloseTabs (${configKey}): `, tabIds); + const count = tabIds.length; + if (count <= 1 || + !configs[configKey]) + return true; + + try { + const granted = await browser.runtime.sendMessage({ + type: Constants.kCOMMAND_CONFIRM_TO_CLOSE_TABS, + windowId: mTargetWindow, + configKey, + tabs + }); + if (granted) { + configs.lastConfirmedToCloseTabs = Date.now(); + configs.grantedRemovingTabIds = Array.from(new Set((configs.grantedRemovingTabIds || []).concat(tabIds))); + log('confirmToCloseTabs: granted ', configs.grantedRemovingTabIds); + reserveToClearGrantedRemovingTabs(); + return true; + } + } + catch(error) { + console.error(error); + } + + return false; +} +TabContextMenu.onTabsClosing.addListener(confirmToCloseTabs); + +function reserveToClearGrantedRemovingTabs() { + const lastGranted = configs.grantedRemovingTabIds.join(','); + setTimeout(() => { + if (configs.grantedRemovingTabIds.join(',') == lastGranted) + configs.grantedRemovingTabIds = []; + }, 1000); +} + + +export function reserveToUpdateTabbarLayout({ reason, timeout } = {}) { + //log('reserveToUpdateTabbarLayout'); + if (reserveToUpdateTabbarLayout.waiting) + clearTimeout(reserveToUpdateTabbarLayout.waiting); + if (reason && !(reserveToUpdateTabbarLayout.reasons & reason)) + reserveToUpdateTabbarLayout.reasons |= reason; + if (typeof timeout != 'number') + timeout = 10; + reserveToUpdateTabbarLayout.timeout = Math.max(timeout, reserveToUpdateTabbarLayout.timeout); + reserveToUpdateTabbarLayout.waiting = setTimeout(() => { + delete reserveToUpdateTabbarLayout.waiting; + reserveToUpdateTabbarLayout.timeout = 0; + updateTabbarLayout(); + }, reserveToUpdateTabbarLayout.timeout); +} +reserveToUpdateTabbarLayout.reasons = 0; +reserveToUpdateTabbarLayout.timeout = 0; + +let mLastVisibleTabId = null; + +function updateTabbarLayout({ reason, reasons, timeout, justNow } = {}) { + if (reason && !reasons) + reasons = reason; + if (reserveToUpdateTabbarLayout.reasons) { + reasons = (reasons || 0) & reserveToUpdateTabbarLayout.reasons; + reserveToUpdateTabbarLayout.reasons = 0; + } + updateTabbarLayout.lastUpdateReasons = reasons; + if (RestoringTabCount.hasMultipleRestoringTabs()) { + log('updateTabbarLayout: skip until completely restored'); + reserveToUpdateTabbarLayout({ + reason: reasons, + timeout: Math.max(100, timeout) + }); + return; + } + const readableReasons = []; + if (configs.debug) { + if (reasons & Constants.kTABBAR_UPDATE_REASON_RESIZE) + readableReasons.push('resize'); + if (reasons & Constants.kTABBAR_UPDATE_REASON_COLLAPSE) + readableReasons.push('collapse'); + if (reasons & Constants.kTABBAR_UPDATE_REASON_EXPAND) + readableReasons.push('expand'); + if (reasons & Constants.kTABBAR_UPDATE_REASON_ANIMATION_END) + readableReasons.push('animation end'); + if (reasons & Constants.kTABBAR_UPDATE_REASON_TAB_OPEN) + readableReasons.push('tab open'); + if (reasons & Constants.kTABBAR_UPDATE_REASON_TAB_CLOSE) + readableReasons.push('tab close'); + if (reasons & Constants.kTABBAR_UPDATE_REASON_TAB_MOVE) + readableReasons.push('tab move'); + if (reasons & Constants.kTABBAR_UPDATE_REASON_VIRTUAL_SCROLL_VIEWPORT_UPDATE) + readableReasons.push('virtual scroll viewport update'); + } + log(`updateTabbarLayout reasons: ${readableReasons.join(',')}`); + + const lastVisibleTab = Tab.getLastVisibleTab(mTargetWindow); + const previousLastVisibleTab = Tab.get(mLastVisibleTabId); + if (previousLastVisibleTab && + (!lastVisibleTab || + lastVisibleTab.id != previousLastVisibleTab.id)) + previousLastVisibleTab.$TST.removeState(Constants.kTAB_STATE_LAST_VISIBLE); + if (lastVisibleTab) + lastVisibleTab.$TST.addState(Constants.kTAB_STATE_LAST_VISIBLE); + mLastVisibleTabId = lastVisibleTab?.id; + + const visibleNewTabButton = document.querySelector('#tabbar:not(.overflow) .after-tabs .newtab-button-box, #tabbar.overflow ~ .after-tabs .newtab-button-box'); + const newTabButtonSize = visibleNewTabButton.offsetHeight; + const extraTabbarTopContainerSize = document.querySelector('#tabbar-top > *').offsetHeight; + const extraTabbarBottomContainerSize = document.querySelector('#tabbar-bottom > *').offsetHeight; + log('height: ', { newTabButtonSize, extraTabbarTopContainerSize, extraTabbarBottomContainerSize }); + + document.documentElement.style.setProperty('--tabbar-top-area-size', `${extraTabbarTopContainerSize}px`); + document.documentElement.style.setProperty('--tabbar-bottom-area-size', `${extraTabbarBottomContainerSize}px`); + document.documentElement.style.setProperty('--after-tabs-area-size', `${newTabButtonSize}px`); + Size.updateContainers(); + + const sidebarWidthInWindow = { ...configs.sidebarWidthInWindow }; + sidebarWidthInWindow[TabsStore.getCurrentWindowId()] = window.innerWidth; + configs.sidebarWidthInWindow = sidebarWidthInWindow; + + if (!(reasons & Constants.kTABBAR_UPDATE_REASON_VIRTUAL_SCROLL_VIEWPORT_UPDATE)) + Scroll.reserveToRenderVirtualScrollViewport({ trigger: 'resized' }); + + if (SidebarItems.normalContainer.classList.contains(Constants.kTABBAR_STATE_OVERFLOW)) { + const updatedAt = updateTabbarLayout.lastScrollbarAutohideUpdatedAt = Date.now(); + window.requestAnimationFrame(() => { + if (updatedAt != updateTabbarLayout.lastScrollbarAutohideUpdatedAt || + !SidebarItems.normalContainer.classList.contains(Constants.kTABBAR_STATE_OVERFLOW)) + return; + + // scrollbar is shown only when hover on Windows 11, Linux, and macOS. + const virtualScrollContainer = document.querySelector('.virtual-scroll-container'); + const scrollbarOffset = mTabBar.offsetWidth - virtualScrollContainer.offsetWidth; + + const lastState = mTabBar.classList.contains(Constants.kTABBAR_STATE_SCROLLBAR_AUTOHIDE); + const newState = scrollbarOffset == 0; + if (lastState == newState) + return; + + mTabBar.classList.toggle(Constants.kTABBAR_STATE_SCROLLBAR_AUTOHIDE, newState); + onLayoutUpdated.dispatch() + }); + } + + if (justNow) + PinnedTabs.reposition({ reasons, timeout, justNow }); + else + PinnedTabs.reserveToReposition({ reasons, timeout, justNow }); +} +updateTabbarLayout.lastUpdateReasons = 0; +updateTabbarLayout.lastScrollbarAutohideUpdatedAt = 0; + + +Scroll.onNormalTabsOverflow.addListener(() => { + log('Normal Tabs Overflow'); + const windowId = TabsStore.getCurrentWindowId(); + SidebarItems.normalContainer.classList.add(Constants.kTABBAR_STATE_OVERFLOW); + mTabBar.classList.add(Constants.kTABBAR_STATE_OVERFLOW); + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_TABBAR_OVERFLOW, + windowId, + }); + window.requestAnimationFrame(() => { + // Tab at the end of the tab bar can be hidden completely or + // partially (newly opened in small tab bar, or scrolled out when + // the window is shrunken), so we need to scroll to it explicitely. + const activeTab = Tab.getActiveTab(windowId); + if (activeTab && !Scroll.isItemInViewport(activeTab)) { + log('scroll to active tab on updateTabbarLayout'); + Scroll.scrollToItem(activeTab); + onLayoutUpdated.dispatch() + return; + } + const lastOpenedTab = Tab.getLastOpenedTab(windowId); + if (updateTabbarLayout.lastUpdateReasons & Constants.kTABBAR_UPDATE_REASON_TAB_OPEN && + !Scroll.isItemInViewport(lastOpenedTab)) { + log('scroll to last opened tab on updateTabbarLayout ', updateTabbarLayout.lastUpdateReasons); + Scroll.scrollToItem(lastOpenedTab, { + anchor: activeTab, + notifyOnOutOfView: true + }); + } + onLayoutUpdated.dispatch() + }); +}); + +Scroll.onNormalTabsUnderflow.addListener(() => { + log('Normal Tabs Underflow'); + SidebarItems.normalContainer.classList.remove(Constants.kTABBAR_STATE_OVERFLOW); + mTabBar.classList.remove(Constants.kTABBAR_STATE_OVERFLOW); + TSTAPI.broadcastMessage({ + type: TSTAPI.kNOTIFY_TABBAR_UNDERFLOW, + windowId: TabsStore.getCurrentWindowId(), + }); + window.requestAnimationFrame(() => { + onLayoutUpdated.dispatch() + }); +}); + + +function onFocus(_event) { + BackgroundConnection.sendMessage({ + type: Constants.kNOTIFY_SIDEBAR_FOCUS + }); +} + +function onBlur(_event) { + BackgroundConnection.sendMessage({ + type: Constants.kNOTIFY_SIDEBAR_BLUR + }); +} + +function onResize(_event) { + reserveToUpdateTabbarLayout({ + reason: Constants.kTABBAR_UPDATE_REASON_RESIZE + }); +} + +function onTransisionEnd(event) { + if (event.pseudoElement || // ignore size change of pseudo elements because they won't change height of tabbar contents + !event.target.parentNode || + !event.target.parentNode.classList.contains('tabs') || // ignore animations on elements not affect to the tab bar scroll size + !/margin|height|border-((top|bottom)-)?/.test(event.propertyName)) + return; + //log('transitionend ', event); + reserveToUpdateTabbarLayout({ + reason: Constants.kTABBAR_UPDATE_REASON_ANIMATION_END + }); +} + +function onBrowserThemeChanged(updateInfo) { + if (!updateInfo.windowId || // reset to default + updateInfo.windowId == mTargetWindow) + applyBrowserTheme(updateInfo.theme); +} + + +ContextualIdentities.onUpdated.addListener(() => { + updateContextualIdentitiesStyle(); + updateContextualIdentitiesSelector(); +}); + +CollapseExpand.onReadyToExpand.addListener(async _tab => { + await nextFrame(); +}); + +CollapseExpand.onUpdated.addListener((tab, options) => { + const reason = options.collapsed ? Constants.kTABBAR_UPDATE_REASON_COLLAPSE : Constants.kTABBAR_UPDATE_REASON_EXPAND ; + reserveToUpdateTabbarLayout({ reason }); +}); + +async function onConfigChange(changedKey) { + const rootClasses = document.documentElement.classList; + switch (changedKey) { + case 'debug': { + EventListenerManager.debug = configs.debug; + if (mInitialized) { + // We have no need to re-update tabs on the startup process. + // Moreover, we should not re-update tabs at the time to avoid + // breaking of initialized tab states. + for (const tab of Tab.getAllTabs(mTargetWindow, { iterator: true })) { + TabsUpdate.updateTab(tab, tab, { forceApply: true }); + tab.$TST.invalidateElement(TabInvalidationTarget.Tooltip); + } + } + rootClasses.toggle('debug', configs.debug); + }; break; + + case 'sidebarPosition': { + const isRight = await isSidebarRightSide(); + rootClasses.toggle('right', isRight); + rootClasses.toggle('left', !isRight); + Indent.update({ force: true }); + }; break; + + case 'baseIndent': + case 'minIndent': + case 'maxTreeLevel': + case 'indentAutoShrink': + case 'indentAutoShrinkOnlyForVisible': + Indent.update({ force: true }); + break; + + case 'faviconizePinnedTabs': + case 'maxFaviconizedPinnedTabsInOneRow': + case 'maxPinnedTabsRowsAreaPercentage': + rootClasses.toggle(Constants.kTABBAR_STATE_FAVICONIZE_PINNED_TABS, configs[changedKey]); + PinnedTabs.reserveToReposition(); + break; + + case 'fadeOutPendingTabs': + document.documentElement.classList.toggle('fade-out-pending-tabs', !!configs[changedKey]); + break; + + case 'fadeOutDiscardedTabs': + document.documentElement.classList.toggle('fade-out-discarded-tabs', !!configs[changedKey]); + break; + + case 'style': + log('reload for changed style'); + location.reload(); + break; + + case 'colorScheme': + document.documentElement.setAttribute('color-scheme', configs.colorScheme); + break; + + case 'inheritContextualIdentityToChildTabMode': + updateContextualIdentitiesSelector(); + break; + + case 'showContextualIdentitiesSelector': + rootClasses.toggle(Constants.kTABBAR_STATE_CONTEXTUAL_IDENTITY_SELECTABLE, configs[changedKey]); + break; + + case 'showNewTabActionSelector': + rootClasses.toggle(Constants.kTABBAR_STATE_NEWTAB_ACTION_SELECTABLE, configs[changedKey]); + break; + + case 'simulateSVGContextFill': + rootClasses.toggle('simulate-svg-context-fill', configs[changedKey]); + break; + + case 'enableWorkaroundForBug1763420_reloadMaskImage': + mReloadMaskImage = configs[changedKey]; + break; + + case 'shiftTabsForScrollbarDistance': + Size.updateTabs(); + break; + + case 'shiftTabsForScrollbarOnlyOnHover': + document.documentElement.classList.toggle('shift-tabs-for-scrollbar-only-on-hover', !!configs[changedKey]); + break; + + default: + if (changedKey.startsWith('chunkedUserStyleRules')) + applyUserStyleRules(); + break; + } +} + +async function isSidebarRightSide() { + const mayBeRight = window.mozInnerScreenX - window.screenX > (window.outerWidth - window.innerWidth) / 2; + if (configs.sidebarPosition == Constants.kTABBAR_POSITION_AUTO && + mayBeRight && + !isRTL() && + !configs.sidebarPositionRighsideNotificationShown) { + if (mTargetWindow != (await browser.windows.getLastFocused({})).id) + return; + + let result; + do { + result = await RichConfirm.show({ + message: browser.i18n.getMessage('sidebarPositionRighsideNotification_message'), + buttons: [ + browser.i18n.getMessage('sidebarPositionRighsideNotification_rightside'), + browser.i18n.getMessage('sidebarPositionRighsideNotification_leftside'), + ], + }); + } while (result.buttonIndex < 0); + + const notificationParams = { + title: browser.i18n.getMessage('sidebarPositionOptionNotification_title'), + message: browser.i18n.getMessage('sidebarPositionOptionNotification_message'), + url: `moz-extension://${location.host}/options/options.html#section-appearance`, + timeout: configs.sidebarPositionOptionNotificationTimeout, + }; + configs.sidebarPositionRighsideNotificationShown = true; + switch (result.buttonIndex) { + case 0: + notify(notificationParams); + break; + + case 1: + default: + configs.sidebarPosition = Constants.kTABBAR_POSITION_LEFT; + notify(notificationParams); + return; + } + } + return configs.sidebarPosition == Constants.kTABBAR_POSITION_AUTO ? + (mayBeRight || isRTL()) : + configs.sidebarPosition == Constants.kTABBAR_POSITION_RIGHT; +} + + +// This must be synchronous and return Promise on demando, to avoid +// blocking to other listeners. +function onMessage(message, _sender, _respond) { + if (!message || + typeof message.type != 'string' || + message.type.indexOf('ws:') != 0) + return; + + if (message.windowId && + message.windowId != mTargetWindow) + return; + + //log('onMessage: ', message, sender); + switch (message.type) { + // for a vital check from SidebarConnection + case Constants.kCOMMAND_PING_TO_SIDEBAR: + return Promise.resolve(true); + + case Constants.kCOMMAND_RELOAD: + log('reload triggered by the reload command'); + location.reload(); + return; + + case Constants.kCOMMAND_SHOW_DIALOG: + return RichConfirm.show({ + ...message.params, + onHidden() { + UserOperationBlocker.unblockIn(mTargetWindow, message.userOperationBlockerParams || {}); + } + }); + + case Constants.kCOMMAND_GET_SIDEBAR_POSITION: + return Promise.resolve(document.documentElement.classList.contains('right') ? + Constants.kTABBAR_POSITION_RIGHT : + Constants.kTABBAR_POSITION_LEFT); + + // for automated tests + case Constants.kCOMMAND_GET_BOUNDING_CLIENT_RECT: { + const range = document.createRange(); + if (message.selector) { + const node = document.querySelector(message.selector); + if (!node) { + range.detach(); + return Promise.resolve(null); + } + range.selectNode(node); + } + else { + range.setStartBefore(document.querySelector(message.startBefore)); + range.setEndAfter(document.querySelector(message.endAfter)); + } + const rect = range.getBoundingClientRect(); + range.detach(); + return Promise.resolve({ + bottom: rect.bottom, + height: rect.height, + left: rect.left, + right: rect.right, + top: rect.top, + width: rect.width, + }); + }; break; + } +} + +const BUFFER_KEY_PREFIX = 'sidebar-'; + +BackgroundConnection.onMessage.addListener(async message => { + switch (message.type) { + case Constants.kCOMMAND_NOTIFY_CONNECTION_READY: + mConnectionOpenCount = message.openCount; + break; + + case Constants.kCOMMAND_BLOCK_USER_OPERATIONS: + UserOperationBlocker.blockIn(mTargetWindow, message); + break; + + case Constants.kCOMMAND_UNBLOCK_USER_OPERATIONS: + UserOperationBlocker.unblockIn(mTargetWindow, message); + break; + + case Constants.kCOMMAND_PROGRESS_USER_OPERATIONS: + UserOperationBlocker.setProgress(message.percentage, mTargetWindow); + break; + + case Constants.kCOMMAND_NOTIFY_TAB_CREATED: + case Constants.kCOMMAND_NOTIFY_TAB_MOVED: + case Constants.kCOMMAND_NOTIFY_TAB_ATTACHED_TO_WINDOW: + if (message.tabId) + await Tab.waitUntilTracked(message.tabId); + reserveToUpdateTabbarLayout({ + reason: Constants.kTABBAR_UPDATE_REASON_TAB_OPEN, + timeout: configs.collapseDuration + }); + break; + + case Constants.kCOMMAND_NOTIFY_TAB_REMOVING: + case Constants.kCOMMAND_NOTIFY_TAB_DETACHED_FROM_WINDOW: { + await Tab.waitUntilTracked(message.tabId); + reserveToUpdateTabbarLayout({ + reason: Constants.kTABBAR_UPDATE_REASON_TAB_CLOSE, + timeout: configs.collapseDuration + }); + }; break; + + case Constants.kCOMMAND_NOTIFY_TAB_SHOWN: + case Constants.kCOMMAND_NOTIFY_TAB_HIDDEN: { + if (BackgroundConnection.handleBufferedMessage({ type: 'shown/hidden', message }, `${BUFFER_KEY_PREFIX}${message.tabId}`)) + return; + await Tab.waitUntilTracked(message.tabId); + const lastMessage = BackgroundConnection.fetchBufferedMessage('shown/hidden', `${BUFFER_KEY_PREFIX}${message.tabId}`); + if (!lastMessage) + return; + if (lastMessage.message.type == Constants.kCOMMAND_NOTIFY_TAB_SHOWN) { + reserveToUpdateTabbarLayout({ + reason: Constants.kTABBAR_UPDATE_REASON_TAB_OPEN + }); + } + else { + reserveToUpdateTabbarLayout({ + reason: Constants.kTABBAR_UPDATE_REASON_TAB_CLOSE + }); + } + }; break; + + case Constants.kCOMMAND_BOOKMARK_TAB_WITH_DIALOG: { + Bookmark.bookmarkTab(Tab.get(message.tabId), { + ...(message.options || {}), + showDialog: true + }); + }; break; + + case Constants.kCOMMAND_BOOKMARK_TABS_WITH_DIALOG: { + Bookmark.bookmarkTabs(mapAndFilter(message.tabIds, id => Tab.get(id)), { + ...(message.options || {}), + showDialog: true + }); + }; break; + + case Constants.kCOMMAND_NOTIFY_TABS_HIGHLIGHTING_IN_PROGRESS: { + const notification = Notifications.add('tabs-highlighing-progress', { + message: browser.i18n.getMessage('tabsHighlightingNotification_message', [message.progress]), + onCreated(notification) { + notification.classList.add('hbox'); + }, + }); + notification.style.backgroundImage = ` + linear-gradient( + 90deg, + Highlight 0%, + Highlight ${message.progress}%, + transparent ${message.progress}%, + transparent 100% + ) + `; + }; break; + + case Constants.kCOMMAND_NOTIFY_TABS_HIGHLIGHTING_COMPLETE: + Notifications.remove('tabs-highlighing-progress'); + break; + } +}); + + +browser.windows.onFocusChanged.addListener(windowId => { + document.documentElement.classList.toggle('active', windowId == mTargetWindow); +}); diff --git a/waterfox/browser/components/sidebar/sidebar/size.js b/waterfox/browser/components/sidebar/sidebar/size.js new file mode 100644 index 000000000000..8b1bd42e19c4 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/size.js @@ -0,0 +1,286 @@ +/* +# 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'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; + +import { + log as internalLogger, + configs, + isRTL, +} from '/common/common.js'; + +function log(...args) { + internalLogger('sidebar/size', ...args); +} + +export const onUpdated = new EventListenerManager(); + +const mPinnedScrollBox = document.querySelector('#pinned-tabs-container'); +const mNormalScrollBox = document.querySelector('#normal-tabs-container'); +const mTabBar = document.querySelector('#tabbar'); + +let mTabHeight = 0; +let mTabXOffset = 0; +let mTabYOffset = 0; +let mTabMarginBlockStart = 0; +let mTabMarginBlockEnd = 0; +let mFavIconSize = 0; +let mFavIconizedTabSize = 0; +let mFavIconizedTabWidth = 0; +let mFavIconizedTabHeight = 0; +let mFavIconizedTabXOffset = 0; +let mFavIconizedTabYOffset = 0; +let mPinnedTabsScrollBoxRect; +let mPinnedTabsContainerWidth +let mNormalTabsScrollBoxRect; +let mNormalTabsViewPortSize = 0; +let mAllTabsAreaSize = 0; + +export function getTabHeight() { + return mTabHeight; +} + +export function getRenderedTabHeight() { + return mTabHeight + mTabYOffset; +} + +export function getTabXOffset() { + return mTabXOffset; +} + +export function getTabYOffset() { + return mTabYOffset; +} + +export function getTabMarginBlockStart() { + return mTabMarginBlockStart; +} + +export function getTabMarginBlockEnd() { + return mTabMarginBlockEnd; +} + +export function getFavIconSize() { + return mFavIconSize; +} + +export function getFavIconizedTabSize() { + return mFavIconizedTabSize; +} + +export function getFavIconizedTabXOffset() { + return mFavIconizedTabYOffset; +} + +export function getFavIconizedTabYOffset() { + return mFavIconizedTabYOffset; +} + +export function getRenderedFavIconizedTabWidth() { + return mFavIconizedTabWidth + mFavIconizedTabXOffset; +} + +export function getRenderedFavIconizedTabHeight() { + return mFavIconizedTabHeight + mFavIconizedTabYOffset; +} + +export function getScrollBoxRect(scrollBox) { + return scrollBox == mPinnedScrollBox ? + (mPinnedTabsScrollBoxRect ||= mPinnedScrollBox.getBoundingClientRect()) : + (mNormalTabsScrollBoxRect ||= mNormalScrollBox.getBoundingClientRect()); +} + +export function getNormalTabsViewPortSize() { + return mNormalTabsViewPortSize; +} + +export function getPinnedTabsContainerWidth() { + return mPinnedTabsContainerWidth; +} + +export function getAllTabsAreaSize() { + return mAllTabsAreaSize; +} + +export function init() { + updateTabs(); + updateContainers(); + matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`).addListener(() => { + updateTabs(); + updateContainers(); + }); +} + +export function clear() { + document.querySelector('#size-definition').textContent = ''; +} + +export function updateTabs() { + // first, calculate actual favicon size. + mFavIconSize = document.querySelector('#dummy-favicon-size-box').offsetHeight; + const scale = Math.max(configs.faviconizedTabScale, 1); + mFavIconizedTabSize = parseInt(mFavIconSize * scale); + log('mFavIconSize / mFavIconizedTabSize ', mFavIconSize, mFavIconizedTabSize); + let sizeDefinition = `:root { + --favicon-size: ${mFavIconSize}px; + --faviconized-tab-size: ${mFavIconizedTabSize}px; + }`; + const dummyFaviconizedTab = document.querySelector('#dummy-faviconized-tab'); + const faviconizedTabStyle = window.getComputedStyle(dummyFaviconizedTab); + mFavIconizedTabWidth = dummyFaviconizedTab.offsetWidth; + mFavIconizedTabHeight = dummyFaviconizedTab.offsetHeight; + // simulating margin collapsing + const favIconizedMarginInlineStart = parseFloat(faviconizedTabStyle.marginInlineStart); + const favIconizedMarginInlineEnd = parseFloat(faviconizedTabStyle.marginInlineEnd); + mFavIconizedTabXOffset = (favIconizedMarginInlineStart > 0 && favIconizedMarginInlineEnd > 0) ? + Math.max(favIconizedMarginInlineStart, favIconizedMarginInlineEnd) : + favIconizedMarginInlineStart + favIconizedMarginInlineEnd; + const favIconizedMarginBlockStart = parseFloat(faviconizedTabStyle.marginBlockStart); + const favIconizedMarginBlockEnd = parseFloat(faviconizedTabStyle.marginBlockEnd); + mFavIconizedTabYOffset = (favIconizedMarginBlockStart > 0 && favIconizedMarginBlockEnd > 0) ? + Math.max(favIconizedMarginBlockStart, favIconizedMarginBlockEnd) : + favIconizedMarginBlockStart + favIconizedMarginBlockEnd; + + const dummyTab = document.querySelector('#dummy-tab'); + const tabStyle = window.getComputedStyle(dummyTab); + mTabXOffset = parseFloat(tabStyle.marginInlineStart) + parseFloat(tabStyle.marginInlineEnd); + // simulating margin collapsing + mTabMarginBlockStart = parseFloat(tabStyle.marginBlockStart); + mTabMarginBlockEnd = parseFloat(tabStyle.marginBlockEnd); + mTabYOffset = (mTabMarginBlockStart > 0 && mTabMarginBlockEnd > 0) ? + Math.max(mTabMarginBlockStart, mTabMarginBlockEnd) : + mTabMarginBlockStart + mTabMarginBlockEnd; + + const substanceRect = dummyTab.querySelector('tab-item-substance').getBoundingClientRect(); + const uiRect = dummyTab.querySelector('tab-item-substance > .ui').getBoundingClientRect(); + const captionRect = dummyTab.querySelector('tab-item-substance > .ui > .caption').getBoundingClientRect(); + const favIconRect = dummyTab.querySelector('tab-favicon').getBoundingClientRect(); + const labelRect = dummyTab.querySelector('tab-label').getBoundingClientRect(); + const closeBoxRect = dummyTab.querySelector('tab-closebox').getBoundingClientRect(); + + let shiftTabsForScrollbarDistance = configs.shiftTabsForScrollbarDistance.trim() || '0'; + if (!/^[0-9\.]+(cm|mm|Q|in|pc|pt|px|em|ex|ch|rem|lh|vw|vh|vmin|vmax|%)$/.test(shiftTabsForScrollbarDistance)) + shiftTabsForScrollbarDistance = '0'; // ignore invalid length + if (shiftTabsForScrollbarDistance == '0') + shiftTabsForScrollbarDistance += 'px'; // it is used with CSS calc() and it requires any length unit for each value. + + // Phase 1: setting `--tab-size` based on the auto-calculated height of the + // dummy tab, which may be expanded by inserted extra tab contents. + const baseLeft = substanceRect.left; + const baseRight = substanceRect.right; + sizeDefinition += `:root { + --tab-size: ${dummyTab.offsetHeight}px; + --tab-substance-size: ${substanceRect.height}px; + --tab-ui-size: ${uiRect.height}px; + --tab-caption-size: ${captionRect.height}px; + --tab-x-offset: ${mTabXOffset}px; + --tab-y-offset: ${mTabYOffset}px; + --tab-height: var(--tab-size); /* for backward compatibility of custom user styles */ + --tab-favicon-start-offset: ${isRTL() ? baseRight - favIconRect.right : favIconRect.left - baseLeft}px; + --tab-favicon-end-offset: ${isRTL() ? favIconRect.left - baseLeft : baseRight - favIconRect.right}px; + --tab-label-start-offset: ${isRTL() ? baseRight - labelRect.right : labelRect.left - baseLeft}px; + --tab-label-end-offset: ${isRTL() ? labelRect.left - baseLeft : baseRight - labelRect.right}px; + --tab-closebox-start-offset: ${isRTL() ? baseRight - closeBoxRect.right : closeBoxRect.left - baseLeft}px; + --tab-closebox-end-offset: ${isRTL() ? closeBoxRect.left - baseLeft : baseRight - closeBoxRect.right}px; + + --tab-burst-duration: ${configs.burstDuration}ms; + --indent-duration: ${configs.indentDuration}ms; + --collapse-duration: ${configs.collapseDuration}ms; + --out-of-view-tab-notify-duration: ${configs.outOfViewTabNotifyDuration}ms; + --visual-gap-hover-animation-delay: ${configs.cancelGapSuppresserHoverDelay}ms; + + --shift-tabs-for-scrollbar-distance: ${shiftTabsForScrollbarDistance}; + }`; + + const sizeDefinitionHolder = document.querySelector('#size-definition'); + const modifiedAtPhase1 = sizeDefinitionHolder.textContent != sizeDefinition; + if (modifiedAtPhase1) + sizeDefinitionHolder.textContent = sizeDefinition; + + // Phase 2: setting `--tab-size` again based on the "sizer" dummy tab. + // In general cases it is sized by the `--tab-size` defined at the phase 1, + // but tab size defined in the user style sheet is always preferred to it. + // The customized tab height is applied only to the "sizer" dummy. + // As the result, TST treats the priority of tab size as: + // user-defined tab size (never expanded by extra tab contents) + // > auto-calculated tab size (may be expanded by extra tab contents) + // > initial tab size + mTabHeight = document.querySelector('#dummy-sizer-tab').offsetHeight; + log('mTabHeight ', mTabHeight); + const finalSizeDefinition = sizeDefinition.replace(/--tab-size:[^;]+;/, `--tab-size: ${mTabHeight}px;`); + const modifiedAtPhase2 = sizeDefinitionHolder.textContent != finalSizeDefinition; + if (modifiedAtPhase2) + sizeDefinitionHolder.textContent = finalSizeDefinition; + + if (modifiedAtPhase1 || modifiedAtPhase2) + onUpdated.dispatch(); +} + +export function updateContainers() { + let modifiedCount = 0; + + mPinnedTabsScrollBoxRect = mPinnedScrollBox.getBoundingClientRect(); + mNormalTabsScrollBoxRect = mNormalScrollBox.getBoundingClientRect(); + + const pinnedContainerBox = mPinnedScrollBox.querySelector('.tabs'); + const pinnedContainerBoxRect = pinnedContainerBox.getBoundingClientRect(); + const pinnedContainerStyle = window.getComputedStyle(pinnedContainerBox, null); + const newPinnedTabsContainerWidth = pinnedContainerBoxRect.width - parseFloat(pinnedContainerStyle.paddingInlineStart) - parseFloat(pinnedContainerStyle.borderInlineStartWidth) - parseFloat(pinnedContainerStyle.paddingInlineEnd) - parseFloat(pinnedContainerStyle.borderInlineEndWidth); + if (newPinnedTabsContainerWidth != mPinnedTabsContainerWidth) + modifiedCount++; + mPinnedTabsContainerWidth = newPinnedTabsContainerWidth; + + const range = document.createRange(); + //range.selectNodeContents(mTabBar); + //range.setEndBefore(mNormalScrollBox); + const normalTabsViewPortPrecedingAreaSize = mPinnedScrollBox.offsetHeight; //range.getBoundingClientRect().height; + range.selectNodeContents(mTabBar); + range.setStartAfter(mNormalScrollBox); + const normalTabsViewPortFollowingAreaSize = range.getBoundingClientRect().height; + const newNormalTabsViewportSize = mTabBar.offsetHeight - normalTabsViewPortPrecedingAreaSize - normalTabsViewPortFollowingAreaSize; + range.detach(); + if (newNormalTabsViewportSize != mNormalTabsViewPortSize) + modifiedCount++; + mNormalTabsViewPortSize = newNormalTabsViewportSize; + + const newAllTabsAreaSize = mTabBar.parentNode.offsetHeight; + if (newAllTabsAreaSize != mAllTabsAreaSize) + modifiedCount++; + mAllTabsAreaSize = newAllTabsAreaSize; + + if (modifiedCount > 0) + onUpdated.dispatch(); +} + +export function calc(expression) { + expression = expression.replace(/^\s*calc\((.+)\)\s*$/, '$1'); + const box = document.createElement('span'); + const style = box.style; + style.display = 'inline-block'; + style.insetInlineStart = 0; + style.height = 0; + style.overflow = 'hidden'; + style.pointerEvents = 'none'; + style.position = 'fixed'; + style.top = 0; + style.zIndex = 0; + + const innerBox = box.appendChild(document.createElement('span')); + const innerStyle = innerBox.style; + innerStyle.display = 'inline-block'; + innerStyle.insetInlineStart = 0; + innerStyle.height = 0; + innerStyle.pointerEvents = 'none'; + innerStyle.position = 'fixed'; + innerStyle.top = `calc(${expression})`; + innerStyle.zIndex = 0; + + document.body.appendChild(box); + const result = innerBox.offsetTop - box.offsetTop; + box.parentNode.removeChild(box); + return result; +} diff --git a/waterfox/browser/components/sidebar/sidebar/styles/base.css b/waterfox/browser/components/sidebar/sidebar/styles/base.css new file mode 100644 index 000000000000..2e9753adcbbe --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/base.css @@ -0,0 +1,1660 @@ +@charset "UTF-8"; +/* +# 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 url("/resources/ui-base.css"); + +:root { + /* default size, they will be overridden. */ + --favicon-size: 16px; + --tab-size: unset; + --tab-group-member-indent: 0px; + --tab-indent: 0px; + --tab-margin-block-start: 0px; + --tab-margin-block-end: 0px; + --tab-margin-inline-start: 0px; + --tab-margin-inline-end: 0px; + --faviconized-tab-size: 28px; + --svg-small-icon-size: 16px; + --new-tab-button-anchor-size: 1.5em; + --tabbar-top-area-size: 0px; + --tabbar-bottom-area-size: 0px; + --after-tabs-area-size: 0px; + --pinned-tabs-area-size: 0px; + --pinned-tabs-container-resizer-size: 2px; + --subpanel-area-size: 0px; + --subpanel-content-size: auto; + + --color-animation: 0.25s ease-out; + --indent-animation: var(--indent-duration) ease-out; + --collapse-animation: var(--collapse-duration) ease-out; + --tab-basic-animation: background var(--color-animation), + color var(--color-animation), + inset-inline-start var(--collapse-animation), + inset-inline-end var(--collapse-animation), + margin-block-start var(--collapse-animation), + margin-block-end var(--collapse-animation), + max-height var(--collapse-animation), + max-width var(--collapse-animation), + min-height var(--collapse-animation), + min-width var(--collapse-animation), + opacity var(--collapse-animation), + /* for extra margin on overlay-scrollbar mode */ + padding-inline-start var(--collapse-animation), + padding-inline-end var(--collapse-animation); + --tab-animation: var(--tab-basic-animation), + background var(--color-animation), + top var(--collapse-animation); + --tab-indent-animation: margin-inline-start var(--indent-animation), + margin-inline-end var(--indent-animation); + --tab-indent-positioning-animation: inset-inline-start var(--indent-animation), + inset-inline-end var(--indent-animation); + + --button-opacity: 0.75; + --button-hover-opacity: 1; + --button-active-opacity: 1; + + --tab-highlighted-highlight: white; + --tab-highlighted-glow: Highlight; + --tab-highlighted-base: transparent; + + /* Default color for "No Decoration" */ + --tab-text-regular: ButtonText; + + --tab-surface: var(--tab-surface-regular); + --tab-text: var(--tab-text-regular); + --tab-text-shadow: none; + + --multiselected-color: transparent; + --multiselected-color-opacity: 0.35; + + /* z-index of elements: we should define them here to keep their strict priorities */ + --invisible-z-index: -100; + --dummy-element-z-index: 1; /* dummy tab */ + --background-z-index: 10; /* background screen */ + --accessible-ui-z-index: 100; /* tabbar itself, and others */ + --over-tabs-z-index: 200; /* buttons after tabs, pinned tabs (required to make pinned tabs clickable) */ + --over-buttons-z-index: 300; /* buttons on buttons after tabs */ + --notification-ui-z-index: 9000; + --tooltip-ui-z-index: 10000; + --blocking-ui-z-index: 20000; /* frontmost of others */ + + --tab-background-z-index: 10; + --tab-extra-contents-behind-z-index: 20; + --tab-indent-z-index: 90; + --tab-base-z-index: 100; + --tab-extra-contents-beside-z-index: 150; + --tab-favicon-z-index: 200; + --tab-burster-z-index: 4000; + --tab-highlighter-z-index: 5000; + --tab-extra-contents-front-z-index: 6000; + --tab-ui-z-index: 9000; + --tab-drop-marker-z-index: 10000; + + --visual-gap-offset: 0px; + --visual-gap-animation: top var(--collapse-animation); + --visual-gap-hover-animation: top var(--collapse-animation) var(--visual-gap-hover-animation-delay); + + /* Some users may use physical properties to customize tab margins, so we should map them to logical propertis for better compatibility. + https://github.com/piroor/treestyletab/wiki/Code-snippets-for-custom-style-rules#change-tab-margin */ + &:not(.rtl), + &:not(.rtl) tab-item { + --tab-margin-block-start: var(--tab-margin-top, 0px); + --tab-margin-block-end: var(--tab-margin-bottom, 0px); + --tab-margin-inline-start: var(--tab-margin-left, 0px); + --tab-margin-inline-end: var(--tab-margin-right, 0px); + } + &.rtl, + &.rtl tab-item { + --tab-margin-block-start: var(--tab-margin-top, 0px); + --tab-margin-block-end: var(--tab-margin-bottom, 0px); + --tab-margin-inline-start: var(--tab-margin-right, 0px); + --tab-margin-inline-end: var(--tab-margin-left, 0px); + } + + &.debug { + * { + outline: 1px dotted rgba(255, 0, 0, 0.5); + } + + /* Show internal tab id in the debug mode */ + tab-item::after { + bottom: 0; + inset-inline-end: 0; + opacity: 0.65; + overflow: hidden; + pointer-events: none; + position: absolute; + text-align: end; + z-index: var(--tab-favicon-z-index); + } + /* override definition for the highlighting marker */ + tab-item[data-tab-id]::after { + content: attr(data-tab-id) !important; + } + tab-item[data-native-tab-group-id]::after { + content: attr(data-native-tab-group-id) !important; + } + } +} + + +body { + margin-block: 0; + margin-inline: 0; + padding-block: 0; + padding-inline: 0; +} + +tab-item { + --tab-current-size: var(--tab-size); + --attention-marker-x-offset: calc(0px - ((var(--tab-current-size) - var(--favicon-size)) / 2)); + --attention-marker-y-offset: calc(0px - ((var(--tab-current-size) - var(--favicon-size)) / 2)); + --attention-marker-gradient: radial-gradient(circle farthest-corner at 50% calc(100% - 14% + 1px), + var(--tab-highlighted-glow) 0, + var(--tab-highlighted-glow) 7%, + rgba(255, 255, 255, 0) 7%); + --attention-marker-gradient-hover: var(--attention-marker-gradient); + /* old style + --attention-marker-gradient: radial-gradient(circle farthest-corner at 50% calc(100% - 3px), + var(--tab-highlighted-highlight) 3%, + var(--tab-highlighted-glow) 10%, + rgba(255, 255, 255, 0) 20%); + --attention-marker-gradient-hover: radial-gradient(circle farthest-corner at 50% calc(100% - 3px), + var(--tab-highlighted-highlight) 3%, + var(--tab-highlighted-glow) 10%, + rgba(255, 255, 255, 0) 23%); + */ + --tab-label-width: 100%; + + &.faviconized { + --attention-marker-x-offset: calc(0px - ((var(--tab-current-size) - var(--favicon-size)) / 2) + ((var(--tab-current-size) - var(--faviconized-tab-size)) / 2)); + --attention-marker-y-offset: calc(0px - ((var(--tab-current-size) - var(--favicon-size)) / 2) + ((var(--tab-current-size) - var(--faviconized-tab-size)))); + } + + &:not(.faviconized):not(.dummy) { + height: var(--tab-size); /* make tab height consistent even if only some tabs have extra contents */ + + tab-item-substance { + height: 100%; /* tab substance should be expanded if it has no extra contents */ + } + } + + &:focus { + outline: none; + } + + &:not(.faviconized):focus tab-label, + &.faviconized:focus tab-favicon { + outline: 1px dotted; + } +} + +.sticky-tab-spacer { + height: var(--tab-size); /* make tab height consistent even if only some tabs have extra contents */ +} + + +@keyframes blink { + 0% { opacity: 0; } + 50% { opacity: 1; } + 100% { opacity: 0; } +} + + +/* This is required to prevent dragging of images (ex. favicon). + Otherwise they are unexpectedly dragged just as an image and + it is downloadable via dropping on the desktop or others. */ +img { + pointer-events: none; +} + +button, +.after-tabs [role="button"] /*, +tab-item, +tab-twisty, +tab-closebox, +tab-sound-button */ { + -moz-user-focus: ignore !important; +} + +.vbox { + align-items: stretch; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + justify-content: flex-start; +} + +.hbox { + align-items: stretch; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-start; +} + +.flexible { + height: 100%; /* to make #content flexible */ +} + + +/* tabbar */ + +:root, +body { + color-scheme: light dark; + font: message-box; /* this is same to Firefox's UI. */ +} + +#tabbar-container, +#tabbar, +#pinned-tabs-container, +#pinned-tabs-container-resizer, +#tabbar-top, +#tabbar-bottom, +#background, +#subpanel-container { + bottom: 0; + inset-inline-start: 0; + inset-inline-end: 0; + margin-block: 0; + margin-inline: 0; + overflow: hidden; + padding-block: 0; + padding-inline: 0; + position: fixed; + top: 0; + z-index: var(--accessible-ui-z-index); +} + +#tabbar-container { + bottom: var(--subpanel-area-size); + top: calc(var(--tabbar-top-area-size) + var(--visual-gap-offset)); + transition: var(--visual-gap-animation); + + :root.hover-on-top-edge:hover & { + top: var(--tabbar-top-area-size) !important; + transition: var(--visual-gap-hover-animation); + } +} + +#tabbar { + position: absolute; + + &.overflow { + bottom: calc(var(--after-tabs-area-size) + var(--tabbar-bottom-area-size)); + } + + :root.initializing & { + opacity: 0; + } + + :root.animation:not(.minimized) & { + transition: margin-block-start var(--collapse-animation); + } +} + +#normal-tabs-container { + margin-block-start: var(--pinned-tabs-area-size); + max-height: 100%; + min-height: 0; + overflow: hidden; +} + +:root.rtl, +:root.rtl tab-item-substance .caption { + direction: rtl; +} + +.virtual-scroll-container { + /* We need to use min-height instead of height for a flexbox. */ + min-height: 0px; /* this will be updated dynamically by scroll.js */ + overflow: hidden; + + :root.animation & { + transition: min-height var(--collapse-animation); + } +} + + +#normal-tabs-container.overflow { + /* Scroll anchoring by Firefox itself may break virtual scrolling, so I disable anchoring and control all by myself. */ + overflow-anchor: none; + overflow-y: scroll; +} + +/* overflow-start-indicator and overflow-end-indicator + ref: https://searchfox.org/mozilla-central/rev/1ef947827852125825dda93d8f4f83d1f55739eb/browser/themes/shared/tabs.css#527-563 */ +.overflow-indicator { + background-repeat: no-repeat; + opacity: 0; + position: fixed; + inset-inline-start: 0; + inset-inline-end: 0; + pointer-events: none; + z-index: var(--over-tabs-z-index); + + :root.animation & { + transition: opacity 150ms ease; + } + + &.start { + top: calc(var(--tabbar-top-area-size) + var(--pinned-tabs-area-size)); + } + &.end { + bottom: calc(var(--subpanel-area-size) + var(--tabbar-bottom-area-size) + var(--after-tabs-area-size)); + } + + #normal-tabs-container.overflow.scrolled &.start, + #normal-tabs-container.overflow:not(.fully-scrolled) &.end { + opacity: 1; + } +} + +.tabs-spacer { + display: none; + min-height: 0; + pointer-events: none; + visibility: hidden; + + :root.animation:not(.minimized) & { + transition: min-height var(--collapse-animation); + } + + #normal-tabs-container.overflow & { + display: block; + } +} + +.sticky-tabs-container { + inset-inline-start: 0; + inset-inline-end: 0; + min-height: 0; + pointer-events: none; + position: fixed; + z-index: var(--tab-ui-z-index); + + &.above { + top: calc(var(--tabbar-top-area-size) + var(--pinned-tabs-area-size)); + } + &.below { + bottom: calc(var(--after-tabs-area-size) + var(--tabbar-bottom-area-size) + var(--tabbar-bottom-area-size) + var(--subpanel-area-size)); + } +} + + +#background { + background-color: -moz-dialog; /* This is required to cover dummy elements. See also: https://github.com/piroor/treestyletab/issues/1703#issuecomment-354646405 */ + pointer-events: none; + z-index: var(--background-z-index); +} + + +/* put scrollbar farside from the content area */ +:root.left:not(.rtl) { + #tabbar { + direction: rtl; + .after-tabs { + direction: ltr; + } + } + #pinned-tabs-container > *, + #normal-tabs-container > * { + direction: ltr; + } +} +:root.right.rtl { + /* put scrollbar farside from the content area */ + #tabbar { + direction: ltr; + .after-tabs { + direction: rtl; + } + } + #pinned-tabs-container > *, + #normal-tabs-container > * { + direction: rtl; + } +} + + + +#pinned-tabs-container, +#normal-tabs-container { + scrollbar-width: thin; +} + + +ul { + flex-grow: 1; + list-style: none; + margin-block: 0; + margin-inline: 0; + padding-block: 0; + padding-inline: 0; +} + + +/* dummy elements to calculate actual size of boxes */ + +#dummy-tabs { + bottom: 0; + inset-inline-start: 0; + inset-inline-end: 0; + overflow-y: scroll; + pointer-events: none; + position: fixed; + z-index: var(--dummy-element-z-index); /* Put it below the background. See also: https://github.com/piroor/treestyletab/issues/1703#issuecomment-354646405 */ + + :root:not(.have-unsynchronized-throbber) & { + clip: rect(0, 0, 0, 0); /* Don't hide animated throbber when its appearance is copied with -moz-element since doing so causes stuttering in the copied animation */ + } +} + +#dummy-favicon-size-box { + background: -moz-dialog; + display: inline-block; + width: 1em; + height: 1em; + max-width: 1em; + max-height: 1em; + min-width: 16px; + min-height: 16px; +} + +#dummy-tab-color-box { + background: -moz-dialog; +} + +#dummy-highlight-color-box { + background: Highlight; + color: HighlightText; +} + +#dummy-shift-tabs-for-scrollbar-distance-box { + width: var(--shift-tabs-for-scrollbar-distance, 0); +} + + +/* tabs */ + +tab-item, +.sticky-tab-spacer { + align-items: stretch; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + justify-content: stretch; + line-height: 1; + list-style: none; + margin-block: 0; + margin-inline: 0; + max-width: 100%; + /*overflow: hidden;*/ /* we should not apply overflow:hidden here to allow rendering box-shadow of contents */ + pointer-events: none; + position: relative; +} + +tab-item-substance { + align-items: center; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-start; + line-height: 1; + margin-block: var(--tab-margin-block-start) var(--tab-margin-block-end); + margin-inline: var(--tab-margin-inline-start) var(--tab-margin-inline-end); + max-width: 100%; + min-height: 0; + /*overflow: hidden;*/ /* we should not apply overflow:hidden here to allow rendering box-shadow of contents */ + opacity: 1; + pointer-events: auto; + position: relative; + z-index: var(--tab-base-z-index); + + .ui { + align-items: stretch; + display: flex; + flex-direction: column; + flex-grow: 1; + flex-wrap: nowrap; + justify-content: flex-start; + max-width: calc(100% - var(--favicon-size)); + } + + .caption { + align-items: center; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-start; + line-height: 1; + margin-block: 0; + margin-inline: 0; + width: 100%; + max-height: var(--tab-size); /* Limit tab contents height for tabs customized as thinner. See also https://github.com/piroor/treestyletab/issues/3478 */ + min-height: 0; + pointer-events: auto; + position: static; + } + + :root:not(.rtl).left tab-item:not(.pinned) &, + :root.rtl.right tab-item:not(.pinned) & { + margin-inline-start: calc(var(--tab-margin-inline-start) + var(--tab-group-member-indent) + var(--tab-indent)); + } + + tab-item.removing &, + tab-item:not(.pinned).collapsed &, + tab-item.duplicating & { + opacity: 0; + } + + tab-item.duplicating & { + transition: none; + } +} + + +/* auto-hidden scrollbar placeholder workaround */ +:root:not(.rtl).left.shift-tabs-for-scrollbar-only-on-hover.on-scrollbar-area:hover, +:root:not(.rtl).left:not(.shift-tabs-for-scrollbar-only-on-hover), +:root.rtl.right.shift-tabs-for-scrollbar-only-on-hover.on-scrollbar-area:hover, +:root.rtl.right:not(.shift-tabs-for-scrollbar-only-on-hover) { + #tabbar.scrollbar-autohide + #normal-tabs-container.overflow + tab-item:not(.pinned) + tab-item-substance { + margin-inline-start: calc(var(--tab-margin-inline-start) + var(--tab-group-member-indent) + var(--tab-indent) + var(--shift-tabs-for-scrollbar-distance)); + } + + #normal-tabs-container.overflow + .native-tab-group-line { + inset-inline-start: calc(var(--tab-group-member-indent) * 0.5 + var(--shift-tabs-for-scrollbar-distance)); + } +} + +:root:not(.rtl).right tab-item:not(.pinned), +:root.rtl.left tab-item:not(.pinned) { + tab-item-substance { + margin-inline-end: calc(var(--tab-margin-inline-end) + var(--tab-group-member-indent) + var(--tab-indent)); + } +} + +/* auto-hidden scrollbar placeholder workaround */ +:root:not(.rtl).right.shift-tabs-for-scrollbar-only-on-hover.on-scrollbar-area:hover, +:root:not(.rtl).right:not(.shift-tabs-for-scrollbar-only-on-hover), +:root.rtl.left.shift-tabs-for-scrollbar-only-on-hover.on-scrollbar-area:hover, +:root.rtl.left:not(.shift-tabs-for-scrollbar-only-on-hover) { + #tabbar.scrollbar-autohide + #normal-tabs-container.overflow + tab-item:not(.pinned) + tab-item-substance { + margin-inline-end: calc(var(--tab-margin-inline-end) + var(--tab-group-member-indent) + var(--tab-indent) + var(--shift-tabs-for-scrollbar-distance)); + } + + #normal-tabs-container.overflow + .native-tab-group-line { + inset-inline-end: calc(var(--tab-group-member-indent) * 0.5 + var(--shift-tabs-for-scrollbar-distance)); + } +} + +tab-item { + vertical-align: middle; + + * { + vertical-align: middle; + } + + tab-favicon { + z-index: var(--tab-favicon-z-index); + } + + &.hidden { + opacity: 0.6; + } + + &.removing, + &:not(.pinned).collapsed, + &.duplicating { + pointer-events: none; + z-index: var(--invisible-z-index); + } + &.removing:not(.pinned.faviconized), + &:not(.pinned.faviconized).collapsed, + &.duplicating { + margin-block-start: calc(0px - var(--tab-current-size) - var(--tab-margin-block-end)); + } + :root.left &.removing.pinned.faviconized { + margin-inline-start: calc(0px - var(--tab-current-size) - var(--tab-margin-inline-end)); + } + :root.right &.removing.pinned.faviconized { + margin-inline-end: calc(0px - var(--tab-current-size) - var(--tab-margin-inline-start)); + } + + &:not(.pinned).collapsed.collapsed-completely { + visibility: hidden; + } + + &.duplicating { + transition: none; + } +} + +:root.animation:not(.minimized) { + tab-item.animation-ready, + tab-item.animation-ready tab-item-substance, + .after-tabs button, + .after-tabs [role="button"] { + transition: var(--tab-animation); + } + + tab-item.animation-ready { + &:not(.expanding), + &:not(.expanding) tab-item-substance, + &.expanding.moving, + &.expanding.moving tab-item-substance { + transition: var(--tab-animation), + var(--tab-indent-animation); + } + } +} + + +tab-item tab-twisty { + :root.left:not(.rtl) &, + :root.right.rtl & { + order: -1; + } + :root.right:not(.rtl) &, + :root.left.rtl & { + order: 10000; + } +} +tab-item tab-closebox { + :root.left:not(.rtl) &, + :root.right.rtl & { + order: 10000; + } + :root.right:not(.rtl) &, + :root.left.rtl & { + order: -1; + } +} + + +tab-item .highlighter, +tab-item .burster, +tab-item.faviconized.unread tab-favicon::before, +tab-item.attention tab-favicon::before, +tab-item .background, +tab-item .native-tab-group-line { + bottom: 0; + inset-inline-start: 0; + inset-inline-end: 0; + pointer-events: none; + position: absolute; + top: 0; +} + +.extra-items-container { + tab-item &.indent, + tab-item &.behind, + tab-item &.front, + .newtab-button & { + bottom: 0; + inset-inline-start: 0; + inset-inline-end: 0; + pointer-events: none; + position: absolute; + top: 0; + } + + tab-item &.above, + tab-item &.below { + align-items: stretch; + display: flex; + flex-direction: column; + flex-wrap: nowrap; + justify-content: flex-start; + overflow: visible; + pointer-events: none; + position: relative; + z-index: var(--tab-extra-contents-beside-z-index); + } + + tab-item &, + .newtab-button & { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: flex-start; + overflow: hidden; + } + + tab-item &.indent { + width: var(--tab-indent); + z-index: var(--tab-indent-z-index); + + :root:not(.rtl).left &, + :root.rtl.right & { + inset-inline-start: var(--tab-group-member-indent); + } + :root:not(.rtl).right &, + :root.rtl.left & { + inset-inline-end: var(--tab-group-member-indent); + } + + .sticky-tabs-container & { + display: none; + } + } + + /* auto-hidden scrollbar placeholder workaround */ + #tabbar.scrollbar-autohide + #normal-tabs-container.overflow tab-item &.indent{ + width: calc(var(--tab-group-member-indent) + var(--tab-indent) + var(--shift-tabs-for-scrollbar-distance)); + } + :root:not(.rtl).left tab-item &.indent, + :root.rtl.right tab-item &.indent { + inset-inline-end: auto; + } + :root:not(.rtl).right tab-item &.indent, + :root.rtl.left tab-item &.indent { + inset-inline-start: auto; + } + + tab-item &.front { + inset-inline-start: var(--tab-label-start-offset); + inset-inline-end: var(--tab-label-end-offset); + justify-content: flex-end; + pointer-events: auto; + z-index: var(--tab-extra-contents-front-z-index); + } + tab-item:not(.faviconized).sound-playing &.front, + tab-item:not(.faviconized).muted &.front { + inset-inline-start: calc(var(--tab-label-start-offset) + var(--svg-small-icon-size)); + } + tab-item.faviconized &.front { + inset-inline-start: var(--faviconized-tab-padding); + inset-inline-end: var(--faviconized-tab-padding); + } + + tab-item &.behind { + z-index: var(--tab-extra-contents-behind-z-index); + } + + .newtab-button & { + pointer-events: auto; + z-index: var(--tab-extra-contents-front-z-index); + } + + :root.newtab-action-selectable + .newtab-button + &, + :root:not(.newtab-action-selectable).contextual-identity-selectable:not(.incognito) + .newtab-button + &, + :root.newtab-action-selectable.contextual-identity-selectable:not(.incognito) + .newtab-button + & { + inset-inline-end: var(--new-tab-button-anchor-size); + } +} + + +.burster { + tab-item & { + z-index: var(--tab-burster-z-index); + } + + tab-item:not(.bursting) & { + display: none; + } +} + +tab-item .highlighter { + z-index: var(--tab-highlighter-z-index); +} + + +tab-item.faviconized.unread tab-favicon::before, +tab-item.attention tab-favicon::before { + bottom: auto; + content: ''; + height: var(--tab-current-size); + inset-inline-start: var(--attention-marker-x-offset); + inset-inline-end: auto; + top: var(--attention-marker-y-offset); + width: var(--tab-current-size); +} + +.background { + tab-item & { + overflow: hidden; /* to crop burster*/ + z-index: var(--tab-background-z-index); + + &.base { + display: none; + + .sticky-tabs-container & { + display: inline-block; + } + } + } +} + + +/* pinned tabs */ + +#pinned-tabs-container { + align-items: stretch; + display: flex; + flex-direction: column; + max-height: var(--pinned-tabs-area-size); + overflow: hidden; + top: calc(var(--tabbar-top-area-size) + var(--visual-gap-offset)); + transition: max-height var(--collapse-animation), + var(--visual-gap-animation); + z-index: var(--over-tabs-z-index); + + :root.hover-on-top-edge:hover & { + top: 0 !important; + transition: max-height var(--collapse-animation), + var(--visual-gap-hover-animation); + } + + &.overflow { + overflow-y: scroll; + } +} + +#pinned-tabs-container-resizer { + background-color: transparent; + cursor: ns-resize; + height: var(--pinned-tabs-container-resizer-size); + top: calc(var(--tabbar-top-area-size) + var(--visual-gap-offset) + max(0px, var(--pinned-tabs-area-size) - var(--pinned-tabs-container-resizer-size))); + transition: background-color var(--color-animation); + z-index: var(--over-tabs-z-index); + + &:hover { + background-color: Highlight; + } + + :root:not(.have-pinned-tabs) & { + display: none; + } +} + +.tabs.pinned { + align-items: stretch; + display: flex; + flex-direction: column; + flex-grow: 0; + flex-wrap: wrap; + justify-content: flex-start; + padding-inline-end: max(1px, calc(100% - (var(--pinned-tab-width, 100%) * var(--pinned-tabs-max-column, 99999)) - (var(--pinned-tab-width, 100%) / 2))); + transition: padding-inline-end var(--collapse-animation); + + :root.faviconize-pinned-tabs & { + align-items: flex-start; + flex-direction: row; + } + + /* auto-hidden scrollbar placeholder workaround */ + :root.left.shift-tabs-for-scrollbar-only-on-hover.on-scrollbar-area:hover + #tabbar.scrollbar-autohide + #pinned-tabs-container.overflow &, + :root.left:not(.shift-tabs-for-scrollbar-only-on-hover) + #tabbar.scrollbar-autohide + #pinned-tabs-container.overflow & { + padding-inline-start: var(--shift-tabs-for-scrollbar-distance); + } + :root.right.shift-tabs-for-scrollbar-only-on-hover.on-scrollbar-area:hover + #tabbar.scrollbar-autohide + #pinned-tabs-container.overflow &, + :root.right:not(.shift-tabs-for-scrollbar-only-on-hover) + #tabbar.scrollbar-autohide + #pinned-tabs-container.overflow & { + padding-inline-end: var(--shift-tabs-for-scrollbar-distance); + } + + :root.hover-on-top-edge:hover & { + top: var(--pinned-position-top) !important; + } + :root.hover-on-top-edge:hover &, + :root.hover-on-top-edge.animation:not(.minimized):hover & { + transition: var(--visual-gap-hover-animation); + } + + tab-closebox { + display: none; + } +} + + +tab-item.faviconized { + --faviconized-tab-padding: calc((var(--tab-current-size) - var(--favicon-size)) / 2); + --tab-current-size: var(--faviconized-tab-size); + height: var(--tab-current-size); + margin-block: 0; + margin-inline: 0; + max-height: var(--tab-current-size); + max-width: var(--tab-current-size); + width: var(--tab-current-size); + + tab-item-substance { + border-block-start: none; + border-inline-start: none; + margin-block: 0; + margin-inline: 0; + padding-block: var(--faviconized-tab-padding); + padding-inline: var(--faviconized-tab-padding); + } + + tab-twisty, + .extra-items-container.indent, + .extra-items-container.above, + .extra-items-container.below { + display: none; + } +} + + +/* Keep label in faviconized tabs for voice operations. See also: https://github.com/piroor/treestyletab/issues/2864 */ +tab-item.faviconized tab-label, +tab-item.faviconized tab-label * { + bottom: 0; + display: flex; + inset-inline-start: 0; + inset-inline-end: 0; + opacity: 0; + overflow: hidden; + position: absolute; + top: 0; +} + +tab-item.faviconized.unread:not(.active) tab-item-substance:not(:hover) tab-favicon::before, +tab-item.attention tab-item-substance:not(:hover) tab-favicon::before { + background-image: var(--attention-marker-gradient); +} + +tab-item.faviconized.unread:not(.active) tab-item-substance:hover tab-favicon::before, +tab-item.attention tab-item-substance:hover tab-favicon::before { + background-image: var(--attention-marker-gradient-hover); +} + +tab-item.attention tab-label { + font-weight: bold; +} + +tab-item.faviconized.active:not([data-drop-position]) tab-item-substance { + /* For high contrast mode */ + outline: 2px solid transparent; + outline-offset: -2px; +} + + +/* tab label */ + +tab-item tab-label { + flex-grow: 1; + overflow: hidden; + position: relative; + white-space: nowrap; + z-index: var(--tab-base-z-index); + + :root:not([data-label-overflow="fade"]) & { + text-overflow: ".."; /*ellipsis*/; + } + + :root[data-label-overflow="fade"] &.overflow { + mask-image: linear-gradient(to left, transparent 0, black 2em); + } + :root[data-label-overflow="fade"] &.overflow.rtl { + direction: rtl; + mask-image: linear-gradient(to right, transparent 0, black 2em); + } +} + + +/* This is for backward compatibility about custom user style rules like https://github.com/piroor/treestyletab/issues/1363 */ +tab-item .label-content { + color: inherit; +} + +.counter { + tab-item:not([data-child-ids]) &, + tab-item:not(.subtree-collapsed) & { + display: none; + } + + &::before { + content: "("; + } + + &::after { + content: ")"; + } +} + + +/* tab-closebox */ + +tab-closebox { + line-height: 0; + opacity: var(--button-opacity); + position: relative; + z-index: var(--tab-ui-z-index); + + &:hover { + /* For high contrast mode */ + outline: thin solid transparent; + } + + &::after { + /* + There are some possible characters for this purpose: + https://en.wikipedia.org/wiki/X_mark + - X: upper case X + * Too narrow + - ×: U+00D7 MULTIPLICATION SIGN (z notation Cartesian product) + * Too small on macOS + - ╳: U+2573 BOX DRAWINGS LIGHT DIAGONAL CROSS + * Too large on Ubuntu + - ☓ : U+2613 SALTIRE (St Andrew's Cross) + * Narrow a little on Windows and macOS + - ✕: U+2715 MULTIPLICATION X + * Too small on macOS + - ✖: U+2716 HEAVY MULTIPLICATION X + * Too small on macOS + - ❌ : U+274C CROSS MARK + * Colored on macOS + - ❎ : U+274E NEGATIVE SQUARED CROSS MARK + * Colored on macOS + * Box around the mark is unnecessary + - ⨉ : U+2A09 N-ARY TIMES OPERATOR + - ⨯: U+2A2F VECTOR OR CROSS PRODUCT + * Too small on macOS + - 🗙: U+1F5D9 CANCELLATION X + * Unavailable on macOS + - 🗴 : U+1F5F4 BALLOT SCRIPT X + * Unavailable on macOS + - 🞩: U+1F7A9 LIGHT SALTIRE + * Unavailable on macOS + So ⨉ (U+2A09) looks good for me on Windows, macOS, and Linux (tested on Ubuntu). + But it is not guaranteed, so now we use SVG icon instead. + */ + -moz-context-properties: fill; + background: url("./icons/close-16.svg") no-repeat center / 100%; + content: ""; + display: inline-block; + fill: var(--tab-text); + height: var(--svg-small-icon-size); + width: var(--svg-small-icon-size); + } + + :root.simulate-svg-context-fill &::after { + background: var(--tab-text); + mask: url("./icons/close-16.svg") no-repeat center / 100%; + } + + tab-item:not(.collapsed) & { + &:hover { + opacity: var(--button-hover-opacity); + } + + &:active { + opacity: var(--button-active-opacity); + } + } + + /* hide closebox on non-active and non-hover tabs, like vertical tabs of Firefox itself */ + tab-item:not(.active):not(#dummy-tab) tab-item-substance:not(:hover) & { + /* but only on regular mode. On the inverted mode, auto-hiding closeboxes produces jumping + of favicons and labels - it is too painful. + See also: https://github.com/piroor/treestyletab/issues/3736 */ + :root.left:not(.rtl) &, + :root.right.rtl & { + display: none; + } + } +} + + +/* contextual identity (aka Container Tab) */ + +.contextual-identity-marker { + --contextual-identity-marker-width: calc(var(--favicon-size) / 5.5); + position: absolute; + z-index: var(--tab-ui-z-index); + + tab-item:not(.faviconized) & { + --contextual-identity-marker-margin: min(calc((var(--tab-current-size) - var(--favicon-size)) / 2), calc(var(--tab-current-size) * 0.15)); + bottom: var(--contextual-identity-marker-margin); + inset-inline-end: 0; + top: var(--contextual-identity-marker-margin); + width: var(--contextual-identity-marker-width); + } + + tab-item.faviconized & { + --contextual-identity-marker-margin: min(calc((var(--tab-current-size) - var(--favicon-size)) / 2), calc(var(--tab-current-size) * 0.33)); + bottom: 0; + height: var(--contextual-identity-marker-width); + inset-inline-start: var(--contextual-identity-marker-margin); + inset-inline-end: var(--contextual-identity-marker-margin); + } +} + + + +/* tab groups */ + +tab-item[type="group"] { + tab-twisty, + tab-favicon, + tab-closebox, + .highlighter, + .contextual-identity-marker { + display: none; + } + + .ui { + max-width: 100%; + } + + tab-item-substance { + max-width: calc(max(var(--tab-label-width), calc(var(--favicon-size) / 2)) + + 0.25em /* margin-inline of tab-label */); + } + + tab-label { + font-weight: bold; + } + + &, + &:hover, + tab-item-substance, + tab-item-substance:hover { + --tab-surface: light-dark(var(--tab-group-color), var(--tab-group-color-invert)) !important; + --tab-border: light-dark(var(--tab-group-color), var(--tab-group-color-invert)) !important; + --tab-text: light-dark(var(--tab-group-color-pale), var(--tab-group-label-text-dark)) !important; + } + + &.subtree-collapsed { + &, + &:hover, + tab-item-substance, + tab-item-substance:hover { + --tab-surface: light-dark(var(--tab-group-color-pale), var(--tab-group-color)) !important; + --tab-text: light-dark(var(--tab-group-color), var(--tab-group-color-pale)) !important; + } + } +} + +tab-item[data-group-id]:not([data-group-id="-1"]) { + --space-xsmall: 0.267rem; /* https://searchfox.org/mozilla-central/rev/7d73613454bfe426fdceb635b33cd3061a69def4/toolkit/themes/shared/design-system/tokens-shared.css#273 */ + --space-xxsmall: calc(0.5 * var(--space-xsmall)); /* https://searchfox.org/mozilla-central/rev/7d73613454bfe426fdceb635b33cd3061a69def4/toolkit/themes/shared/design-system/tokens-shared.css#272 */ + --space-medium: calc(3 * var(--space-xsmall)); /* https://searchfox.org/mozilla-central/rev/7d73613454bfe426fdceb635b33cd3061a69def4/toolkit/themes/shared/design-system/tokens-shared.css#275 */ + --tab-group-member-indent: var(--space-medium); /* https://searchfox.org/mozilla-central/rev/7d73613454bfe426fdceb635b33cd3061a69def4/browser/themes/shared/tabbrowser/tabs.css#1016 */ + --tab-group-line-color: light-dark(var(--tab-group-color), var(--tab-group-color-invert)); + + .native-tab-group-line { + background: var(--tab-group-line-color); + display: block; + max-width: 2px; + min-width: 2px; + opacity: 1; + } + &.collapsed .native-tab-group-line { + opacity: 0; + } + &.animation-ready .native-tab-group-line { + transition: var(--tab-indent-positioning-animation), + opacity var(--collapse-animation); + } +} + +.native-tab-group-line { + :root:not(.rtl).left &, + :root.rtl.right & { + inset-inline-start: calc(var(--tab-group-member-indent) * 0.5); + inset-inline-end: auto; + } + + :root:not(.rtl).right &, + :root.rtl.left & { + inset-inline-start: auto; + inset-inline-end: calc(var(--tab-group-member-indent) * 0.5); + } +} + +tab-item[type="group-collapsed-members-counter"] { + tab-twisty, + tab-favicon, + tab-closebox, + .highlighter, + .contextual-identity-marker { + display: none; + } + + &, + &:hover, + tab-item-substance, + tab-item-substance:hover { + --tab-surface: transparent !important; + --tab-border: transparent !important; + --tab-text: light-dark(var(--tab-group-color), var(--tab-group-color-pale)) !important; + } +} + + +/* non-tab items */ + +#tabbar-top, +#tabbar-bottom, +#tabbar ~ .after-tabs { + height: 0; + inset-inline-start: 0; + inset-inline-end: 0; + overflow: visible; + position: absolute; + z-index: var(--over-tabs-z-index); +} + +#tabbar-top { + top: 0; +} + +#tabbar-bottom { + bottom: 0; + justify-content: flex-end; + top: auto; +} + +.after-tabs { + #tabbar.overflow &, + #tabbar:not(.overflow) ~ & { + display: none; + } + + #tabbar ~ & { + bottom: var(--tabbar-bottom-area-size); + justify-content: flex-end; + } + + .newtab-action-selector-anchor, + .contextual-identities-selector-anchor { + background: none; + bottom: 0; + color: var(--tab-text); + display: none; + max-width: var(--new-tab-button-anchor-size); + opacity: 0; + overflow: hidden; + padding-block: calc((var(--new-tab-button-anchor-size) - var(--svg-small-icon-size)) / 2); + padding-inline: calc((var(--new-tab-button-anchor-size) - var(--svg-small-icon-size)) / 2); + pointer-events: none; + position: absolute; + top: 0; + transition: opacity var(--collapse-animation); + z-index: var(--over-buttons-z-index); + } +} + + +.newtab-button-box { + position: relative; +} + +.newtab-button { + background: none transparent; + margin-block: 0; + margin-inline: 0; + text-align: center; + + &::after { + -moz-context-properties: fill; + background: url("./icons/new-16.svg") no-repeat center / 100%; + content: ""; + display: inline-block; + fill: var(--tab-text); + height: var(--svg-small-icon-size); + opacity: var(--button-opacity); + width: var(--svg-small-icon-size); + } + :root.simulate-svg-context-fill &::after { + background: var(--tab-text); + mask: url("./icons/new-16.svg") no-repeat center / 100%; + } + + &:hover::before { + opacity: var(--button-hover-opacity); + } +} + + +:root.newtab-action-selectable + .after-tabs + .newtab-button-box:hover + .newtab-action-selector-anchor, +.after-tabs + .newtab-action-selector-anchor.open, +:root.contextual-identity-selectable:not(.incognito) + .after-tabs + .newtab-button-box:hover + .contextual-identities-selector-anchor:not([disabled="true"]), +.after-tabs + .contextual-identities-selector-anchor.open { + pointer-events: auto; + opacity: 1; +} + +:root.contextual-identity-selectable + .after-tabs + .newtab-action-selector-anchor, +:root:not(.contextual-identity-selectable) + .after-tabs + .newtab-action-selector-anchor, +:root.contextual-identity-selectable + .after-tabs + .contextual-identities-selector-anchor { + border-width: 0; + border-inline-end-width: 1px; + display: flex; + inset-inline-start: 0; +} + +.newtab-action-selector-anchor::-moz-focus-inner, +.contextual-identities-selector-anchor::-moz-focus-inner { + border: none; +} + +#subpanel-toggler::before { + content: "▼"; + font-size: 0.65em; + margin-block: 0.125em; + margin-inline: 0.125em; +} + +.newtab-action-selector-anchor::after, +.contextual-identities-selector-anchor::after, +#subpanel-selector-anchor::after { + -moz-context-properties: fill; + background: url("./icons/ArrowheadDown.svg") no-repeat center / 60%; + content: ""; + display: inline-block; + fill: var(--tab-text); + height: var(--svg-small-icon-size); + line-height: 1; + margin-block-start: calc((var(--favicon-size) - var(--svg-small-icon-size)) / 2); + max-height: var(--favicon-size); + max-width: var(--favicon-size); + width: var(--svg-small-icon-size); +} +:root.simulate-svg-context-fill .newtab-action-selector-anchor::after, +:root.simulate-svg-context-fill .contextual-identities-selector-anchor::after, +:root.simulate-svg-context-fill #subpanel-selector-anchor::after { + background: var(--tab-text); + mask: url("./icons/ArrowheadDown.svg") no-repeat center / 60%; +} + + +#out-of-view-tab-notifier { + background: transparent repeat-x bottom; + background-image: linear-gradient( + to top, + var(--tab-highlighted-glow) 0, + transparent 100% + ); + display: none; + margin-block-start: calc(0 - var(--tab-size)); + min-height: var(--tab-size); + pointer-events: none; + position: relative; + opacity: 0; + + &.notifying { + display: block; + + :root:not(.animation) & { + opacity: 1; + } + + :root.animation:not(.minimized) & { + animation: blink var(--out-of-view-tab-notify-duration) ease-in-out 1; + } + } +} + + +/* notification message */ + +#notifications { + background: var(--tab-surface, var(--bg-color)); + bottom: var(--subpanel-area-size); + color: var(--tab-text); + font-size: var(--svg-small-icon-size); + height: calc(var(--svg-small-icon-size) * 1.5); + inset-inline-start: 0; + inset-inline-end: 0; + line-height: 1; + opacity: 0; + padding-block: calc(var(--svg-small-icon-size) * 0.25); + padding-inline: 0; + pointer-events: none; + position: fixed; + transition: opacity var(--collapse-animation); + z-index: var(--notification-ui-z-index); + + &.shown { + opacity: 0.85; + + &:hover { + opacity: 1; + } + } + + &::before { + content: ""; + display: inline-block; + white-space: nowrap; + } +} + +#notification_tabs-highlighing-progress { + background-position: center bottom; + background-repeat: no-repeat; + background-size: 100% 0.2em; + font: message-box; + padding-block-end: 0.25em; + width: 100%; +} + + +/* sub panel */ + +#subpanel-container { + top: auto; + height: var(--subpanel-area-size); +} + +iframe#subpanel { + border: none; + height: var(--subpanel-content-size); + margin-block: 0; + margin-inline: 0; + max-height: none; + max-width: 100%; + min-height: 0; + min-width: 0; + padding-block: 0; + padding-inline: 0; + width: 100%; + + #subpanel-container.collapsed & { + visibility: collapse; + } +} + +#subpanel-header { + background: var(--tab-surface); + border-block-start: 1px solid var(--tab-border); + display: flex; + flex-direction: row; + justify-content: space-between; + line-height: 1; + margin-block: 0; + margin-inline: 0; + padding-block: 0.3em 0.2em; + padding-inline: 0.2em; + + &.resizable { + cursor: ns-resize; + } + + #subpanel-container:not(.collapsed) & { + border-block-end: 1px solid var(--tab-border); + } +} + +#subpanel-header-main { + display: flex; + flex-grow: 1; + justify-content: flex-start; + overflow: hidden; + text-overflow: ellipsis; +} + +#subpanel-selector-anchor, +#subpanel-toggler { + align-items: center; + background: none; + border: none; + color: var(--tab-text); + display: flex; + flex-direction: row; + font: -moz-button; + padding-block: 0; + padding-inline: 0.5em 0; +} + +#subpanel-selector-anchor { + cursor: default; + + > * { + pointer-events: none; + } +} + +span.icon { + #subpanel-selector-anchor &, + #subpanel-selector & { + > img { + height: 1em; + max-height: 1em; + max-width: 1em; + width: 1em; + } + } + + #subpanel-selector-anchor & > img:not([src]) { + display: none; + } + + #subpanel-selector & > :not([src]) { + visibility: hidden; + } + + #subpanel-selector & { + margin-inline-end: 0.25em; + } +} + +#subpanel-toggler { + cursor: default; + padding-block: 0; + padding-inline: 0.5em; + max-width: 1.5em; + + #subpanel-container.collapsed &::before { + content: "▲"; + } +} + + +/* fake context menu */ + +#tabContextMenu:not([data-tab-id]) li.extra { + display: none !important; +} + + +/* bookmark dialog */ + +.rich-confirm .bookmark-dialog { + input[type="text"], + button { + max-width: 100%; + } +} + + +/* blocking UI */ + +#blocking-screen { + bottom: 0; + display: none; + inset-inline-start: 0; + inset-inline-end: 0; + position: fixed; + top: 0; + z-index: var(--blocking-ui-z-index); + + :root.blocking-throbber &, + :root.blocking-shade & { + background: rgba(0, 0, 0, 0.01); + } + :root.animation.blocking-throbber:not(.minimized) &, + :root.animation.blocking-shade:not(.minimized) & { + background: rgba(0, 0, 0, 0.35); + } + + :root.blocking & { + display: block; + } + + :root:not(.animation).blocking-throbber & *, + :root:not(.animation).blocking-shade & * { + visibility: hidden; + } +} + + +/* +MenuUI.js workaround: prevent menu elements revealed by the custom user styles. +See also: https://github.com/piroor/treestyletab/issues/2777 +*/ +body > ul[id$="selector"] { + opacity: 0; +} + + +/* Workaround for issues */ + +/* + Workaround for https://github.com/piroor/treestyletab/issues/3413 + ( https://bugzilla.mozilla.org/show_bug.cgi?id=1875100 ) + The RAM usage will be unexpectedly get increased when the window is + minimized and it contains animated background image. To avoid it + we deactivate all dangerous (possibly from outside) background images + while the window is minimized. +*/ +:root.minimized { + --browser-bg-images: none !important; + --theme-images-theme_frame: none !important; +} + diff --git a/waterfox/browser/components/sidebar/sidebar/styles/drag-and-drop.css b/waterfox/browser/components/sidebar/sidebar/styles/drag-and-drop.css new file mode 100644 index 000000000000..ea6cf25fb644 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/drag-and-drop.css @@ -0,0 +1,166 @@ +@charset "UTF-8"; +/* +# 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/. +*/ + +:root { + --tab-dropmarker-size: 0.15em; + --tab-dropmarker: -moz-fieldtext; +} + +tab-item.dragging { + opacity: 0.5 !important; +} + +tab-item[data-drop-position="self"] { + &:not([data-next-group-color]) tab-item-substance { + outline: var(--tab-dropmarker-size) solid var(--tab-dropmarker) !important; + outline-offset: calc(0px - var(--tab-dropmarker-size)); + -moz-outline-radius: calc(var(--tab-dropmarker-size) * 2); + } + + &[data-next-group-color="blue"] { + --next-group-color: var(--tab-group-color-blue); + --next-group-color-invert: var(--tab-group-color-blue-invert); + --next-group-color-pale: var(--tab-group-color-blue-pale); + } + &[data-next-group-color="purple"] { + --next-group-color: var(--tab-group-color-purple); + --next-group-color-invert: var(--tab-group-color-purple-invert); + --next-group-color-pale: var(--tab-group-color-purple-pale); + } + &[data-next-group-color="cyan"] { + --next-group-color: var(--tab-group-color-cyan); + --next-group-color-invert: var(--tab-group-color-cyan-invert); + --next-group-color-pale: var(--tab-group-color-cyan-pale); + } + &[data-next-group-color="orange"] { + --next-group-color: var(--tab-group-color-orange); + --next-group-color-invert: var(--tab-group-color-orange-invert); + --next-group-color-pale: var(--tab-group-color-orange-pale); + } + &[data-next-group-color="yellow"] { + --next-group-color: var(--tab-group-color-yellow); + --next-group-color-invert: var(--tab-group-color-yellow-invert); + --next-group-color-pale: var(--tab-group-color-yellow-pale); + } + &[data-next-group-color="pink"] { + --next-group-color: var(--tab-group-color-pink); + --next-group-color-invert: var(--tab-group-color-pink-invert); + --next-group-color-pale: var(--tab-group-color-pink-pale); + } + &[data-next-group-color="green"] { + --next-group-color: var(--tab-group-color-green); + --next-group-color-invert: var(--tab-group-color-green-invert); + --next-group-color-pale: var(--tab-group-color-green-pale); + } + &[data-next-group-color="gray"] { + --next-group-color: var(--tab-group-color-grey); + --next-group-color-invert: var(--tab-group-color-grey-invert); + --next-group-color-pale: var(--tab-group-color-grey-pale); + } + &[data-next-group-color="red"] { + --next-group-color: var(--tab-group-color-red); + --next-group-color-invert: var(--tab-group-color-red-invert); + --next-group-color-pale: var(--tab-group-color-red-pale); + } +} + +tab-item[data-drop-position]:not([data-drop-position="self"]) tab-item-substance::before { + content: ""; + display: block; + max-height: var(--tab-dropmarker-size); + min-height: var(--tab-dropmarker-size); + background: var(--tab-dropmarker) !important; + border-radius: var(--tab-dropmarker-size); + overflow: hidden; + position: absolute; + z-index: var(--tab-drop-marker-z-index); +} +tab-item[data-drop-position]:not([data-drop-position="self"]).faviconized tab-item-substance::before { + max-height: none; + max-width: var(--tab-dropmarker-size); + min-height: 0; + min-width: var(--tab-dropmarker-size); +} + +tab-item:not(.faviconized)[data-drop-position="before"] tab-item-substance::before { + inset-inline-start: 0; + inset-inline-end: 0; + top: 0; +} + +tab-item:not(.faviconized)[data-drop-position="after"] tab-item-substance::before { + bottom: 0; + inset-inline-start: 0; + inset-inline-end: 0; +} + +tab-item.faviconized[data-drop-position="before"] tab-item-substance::before { + bottom: 0; + inset-inline-start: 0; + top: 0; +} + +tab-item.faviconized[data-drop-position="after"] tab-item-substance::before { + bottom: 0; + inset-inline-end: 0; + top: 0; +} + + +.item-drop-blocker { + display: none; + min-height: calc(var(--favicon-size) / 5); + min-width: calc(var(--favicon-size) / 5); + position: fixed; + z-index: var(--blocking-ui-z-index); + + :root.debug & { + background: rgba(255, 0, 0, 0.5); + } + + :root.tab-dragging &, + :root.link-dragging & { + display: block; + } + + &#item-drop-blocker-top { + inset-inline-start: 0; + inset-inline-end: 0; + top: 0; + } + + &#item-drop-blocker-right { + bottom: 0; + inset-inline-end: 0; + top: 0; + } + + &#item-drop-blocker-bottom { + bottom: 0; + inset-inline-start: 0; + inset-inline-end: 0; + } + + &#item-drop-blocker-left { + bottom: 0; + inset-inline-start: 0; + top: 0; + } +} + + +/* notification message */ + +#notification_tab-drag-behavior-description { + white-space: nowrap; + animation: marquee linear 20s infinite; +} + +@keyframes marquee { + 0% { inset-inline-start: 100%; transform: translateX(0); } + 100% { inset-inline-start: 0; transform: translateX(-100%); } +} diff --git a/waterfox/browser/components/sidebar/sidebar/styles/favicon.css b/waterfox/browser/components/sidebar/sidebar/styles/favicon.css new file mode 100644 index 000000000000..3f536ccfa07c --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/favicon.css @@ -0,0 +1,288 @@ +@charset "UTF-8"; +/* +# 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/. +*/ + +tab-favicon { + display: inline-block; + font-size: var(--favicon-size); + height: var(--favicon-size); + margin-block: 0; + margin-inline: 0; + max-height: var(--favicon-size); + max-width: var(--favicon-size); + min-height: var(--favicon-size); + min-width: var(--favicon-size); + padding-block: 0; + padding-inline: 0; + position: relative; + white-space: pre; + width: var(--favicon-size); + z-index: var(--tab-base-z-index); + + --favicon-base-z-index: 20; + --favicon-preferred-image-z-index: 100; + + tab-item &, + tab-item & * { + vertical-align: baseline; + } + tab-item & * { + position: relative; + z-index: var(--favicon-base-z-index); + } +} + +.favicon-image { + max-height: var(--favicon-size); + max-width: var(--favicon-size); + + /* https://searchfox.org/mozilla-central/rev/b7b6aa5e8ffc27bc70d4c129c95adc5921766b93/browser/themes/shared/tabbrowser/tabs.css#466-503 */ + :root.fade-out-pending-tabs tab-item.pending &, + :root.fade-out-discarded-tabs tab-item:not(.pending).discarded & { + filter: grayscale(100%); + @media (prefers-color-scheme: dark) { + filter: grayscale(100%) invert(); + } + opacity: 0.5; + transition-property: filter, opacity; + transition-duration: 1s; + } + :root.fade-out-pending-tabs tab-item:not(.pending) &, + :root.fade-out-discarded-tabs tab-item:not(.pending) & { + transition: none; + } +} + +.favicon-builtin::before, +.favicon-sharing-state::before, +.favicon-sticky-state::after { + -moz-context-properties: fill; + background: url("/resources/icons/defaultFavicon.svg") no-repeat center / 100%; + content: ""; + display: inline-block; + fill: var(--tab-text); + height: var(--svg-small-icon-size); + line-height: 1; + margin-block-start: calc((var(--favicon-size) - var(--svg-small-icon-size)) / 2); + max-height: var(--favicon-size); + max-width: var(--favicon-size); + width: var(--svg-small-icon-size); +} +:root.simulate-svg-context-fill tab-item { + .favicon-builtin::before, + .favicon-sharing-state::before, + .favicon-sticky-state::after { + background: var(--tab-text); + mask: url("/resources/icons/defaultFavicon.svg") no-repeat center / 100%; + } +} + +tab-item.group-tab .favicon-builtin::before { + background: url("/resources/icons/folder.svg") no-repeat center / 100%; +} +:root.simulate-svg-context-fill tab-item.group-tab .favicon-builtin::before { + background: var(--tab-text); + mask: url("/resources/icons/folder.svg") no-repeat center / 100%; +} + + +tab-item[data-current-uri^="chrome:"] .favicon-builtin::before { + background: url("/resources/icons/defaultFavicon.svg") no-repeat center / 100%; +} +:root.simulate-svg-context-fill tab-item[data-current-uri^="chrome:"] .favicon-builtin::before { + background: var(--tab-text); + mask: url("/resources/icons/defaultFavicon.svg") no-repeat center / 100%; +} + +tab-item[data-current-uri^="about:addons"] .favicon-builtin::before { + background: url("/resources/icons/extensions.svg") no-repeat center / 100%; +} +:root.simulate-svg-context-fill tab-item[data-current-uri^="about:addons"] .favicon-builtin::before { + background: var(--tab-text); + mask: url("/resources/icons/extensions.svg") no-repeat center / 100%; +} + +tab-item[data-current-uri^="about:debugging"] .favicon-builtin::before { + background: url("/resources/icons/developer.svg") no-repeat center / 100%; +} +:root.simulate-svg-context-fill tab-item[data-current-uri^="about:debugging"] .favicon-builtin::before { + background: var(--tab-text); + mask: url("/resources/icons/developer.svg") no-repeat center / 100%; +} + +tab-item[data-current-uri^="about:devtools-toolbox"] .favicon-builtin::before { + background: url("/resources/icons/window.svg") no-repeat center / 100%; +} +:root.simulate-svg-context-fill tab-item[data-current-uri^="about:devtools-toolbox"] .favicon-builtin::before { + background: var(--tab-text); + mask: url("/resources/icons/window.svg") no-repeat center / 100%; +} + +tab-item[data-current-uri^="about:logins"] .favicon-builtin::before, +:root.simulate-svg-context-fill tab-item[data-current-uri^="about:logins"] .favicon-builtin::before { + background: url("/resources/icons/lockwise.svg") no-repeat center / 100%; + mask: none; +} + +tab-item[data-current-uri^="about:performance"] .favicon-builtin::before, +tab-item[data-current-uri^="about:processes"] .favicon-builtin::before { + background: url("/resources/icons/performance.svg") no-repeat center / 100%; +} +:root.simulate-svg-context-fill tab-item[data-current-uri^="about:performance"] .favicon-builtin::before, +:root.simulate-svg-context-fill tab-item[data-current-uri^="about:processes"] .favicon-builtin::before { + background: var(--tab-text); + mask: url("/resources/icons/performance.svg") no-repeat center / 100%; +} + +tab-item[data-current-uri^="about:config"] .favicon-builtin::before, +tab-item[data-current-uri^="about:preferences"] .favicon-builtin::before { + background: url("/resources/icons/settings.svg") no-repeat center / 100%; +} +:root.simulate-svg-context-fill tab-item[data-current-uri^="about:config"] .favicon-builtin::before, +:root.simulate-svg-context-fill tab-item[data-current-uri^="about:preferences"] .favicon-builtin::before { + background: var(--tab-text); + mask: url("/resources/icons/settings.svg") no-repeat center / 100%; +} + +tab-item[data-current-uri^="about:privatebrowsing"] .favicon-builtin::before, +:root.simulate-svg-context-fill tab-item[data-current-uri^="about:privatebrowsing"] .favicon-builtin::before { + background: url("/resources/icons/privatebrowsing-favicon.svg") no-repeat center / 100%; + mask: none; +} + +tab-item[data-current-uri^="about:profiling"] .favicon-builtin::before { + background: url("/resources/icons/profiler-stopwatch.svg") no-repeat center / 100%; +} +:root.simulate-svg-context-fill tab-item[data-current-uri^="about:profiling"] .favicon-builtin::before { + background: var(--tab-text); + mask: url("/resources/icons/profiler-stopwatch.svg") no-repeat center / 100%; +} + +tab-item[data-current-uri^="about:protections"] .favicon-builtin::before { + background: url("/resources/icons/dashboard.svg") no-repeat center / 100%; +} +:root.simulate-svg-context-fill tab-item[data-current-uri^="about:protections"] .favicon-builtin::before { + background: var(--tab-text); + mask: url("/resources/icons/dashboard.svg") no-repeat center / 100%; +} + +tab-item[data-current-uri^="about:robots"] .favicon-builtin::before, +:root.simulate-svg-context-fill tab-item[data-current-uri^="about:robots"] .favicon-builtin::before { + background: url("/resources/icons/robot.ico") no-repeat center / 100%; + mask: none; +} + +tab-item[data-current-favicon-uri="chrome://global/skin/icons/blocked.svg"] .favicon-builtin::before { + background: url("/resources/icons/blocked.svg") no-repeat center / 100%; +} +:root.simulate-svg-context-fill tab-item[data-current-favicon-uri="chrome://global/skin/icons/blocked.svg"] .favicon-builtin::before { + background: var(--tab-text); + mask: url("/resources/icons/blocked.svg") no-repeat center / 100%; +} + +tab-item[data-current-favicon-uri="chrome://global/skin/icons/info.svg"] .favicon-builtin::before { + background: url("/resources/icons/info.svg") no-repeat center / 100%; +} +:root.simulate-svg-context-fill tab-item[data-current-favicon-uri="chrome://global/skin/icons/info.svg"] .favicon-builtin::before { + background: var(--tab-text); + mask: url("/resources/icons/info.svg") no-repeat center / 100%; +} + +tab-item[data-current-favicon-uri="chrome://global/skin/icons/warning.svg"] .favicon-builtin::before { + background: url("/resources/icons/warning.svg") no-repeat center / 100%; +} +:root.simulate-svg-context-fill tab-item[data-current-favicon-uri="chrome://global/skin/icons/warning.svg"] .favicon-builtin::before { + background: var(--tab-text); + mask: url("/resources/icons/warning.svg") no-repeat center / 100%; +} + + +tab-item.group-tab tab-favicon:not(.error) .favicon-image[src] { + bottom: 0; + inset-inline-end: 0; + max-height: 10px; + max-width: 10px; + position: absolute; + z-index: var(--favicon-preferred-image-z-index); +} + + +tab-favicon.error .favicon-image, +.favicon-image:not([src]), +tab-item[data-current-uri^="chrome:"] .favicon-image, +tab-item[data-current-uri^="about:addons"] .favicon-image, +tab-item[data-current-uri^="about:config"] .favicon-image, +tab-item[data-current-uri^="about:debugging"] .favicon-image, +tab-item[data-current-uri^="about:devtools-toolbox"] .favicon-image, +tab-item[data-current-uri^="about:logins"] .favicon-image, +tab-item[data-current-uri^="about:performance"] .favicon-image, +tab-item[data-current-uri^="about:preferences"] .favicon-image, +tab-item[data-current-uri^="about:privatebrowsing"] .favicon-image, +tab-item[data-current-uri^="about:processes"] .favicon-image, +tab-item[data-current-uri^="about:profiling"] .favicon-image, +tab-item[data-current-uri^="about:protections"] .favicon-image, +tab-item[data-current-uri^="about:robots"] .favicon-image, +tab-item[data-current-favicon-uri="chrome://global/skin/icons/blocked.svg"] .favicon-image, +tab-item[data-current-favicon-uri="chrome://global/skin/icons/info.svg"] .favicon-image, +tab-item[data-current-favicon-uri="chrome://global/skin/icons/warning.svg"] .favicon-image, +tab-item:not(.group-tab + ):not([data-current-uri^="chrome:"] + ):not([data-current-uri^="about:addons"] + ):not([data-current-uri^="about:config"] + ):not([data-current-uri^="about:debugging"] + ):not([data-current-uri^="about:logins"] + ):not([data-current-uri^="about:devtools-toolbox"] + ):not([data-current-uri^="about:performance"] + ):not([data-current-uri^="about:preferences"] + ):not([data-current-uri^="about:privatebrowsing"] + ):not([data-current-uri^="about:processes"] + ):not([data-current-uri^="about:profiling"] + ):not([data-current-uri^="about:protections"] + ):not([data-current-uri^="about:robots"] + ):not([data-current-favicon-uri="chrome://global/skin/icons/blocked.svg"] + ):not([data-current-favicon-uri="chrome://global/skin/icons/info.svg"] + ):not([data-current-favicon-uri="chrome://global/skin/icons/warning.svg"]) + tab-favicon:not(.error) .favicon-image[src] ~ .favicon-builtin::before, +tab-item.loading .favicon-image, +tab-item.loading .favicon-builtin::before { + display: none !important; +} + + +/* Sticky status */ + +.favicon-sticky-state { + --svg-small-icon-size: 12px; + inset-inline-start: calc(var(--svg-small-icon-size) - var(--favicon-size)); + position: absolute; + top: calc(var(--svg-small-icon-size) - var(--favicon-size)); + + &::before { + background: var(--tab-text-inverted); + border-radius: 150%; + content: ""; + display: inline-block; + height: var(--svg-small-icon-size); + inset-inline-start: -10%; + opacity: 0.75; + position: absolute; + top: 10%; + transform: scale(1.3, 1.3); + width: var(--svg-small-icon-size); + } + + tab-item:not(.sticky) &, + tab-item.pinned & { + display: none; + } + + tab-item.sticky &::after { + background-image: url("./icons/pin-12.svg") + } + :root.simulate-svg-context-fill tab-item.sticky &::after { + mask-image: url("./icons/pin-12.svg"); + } +} diff --git a/waterfox/browser/components/sidebar/sidebar/styles/highlighter.css b/waterfox/browser/components/sidebar/sidebar/styles/highlighter.css new file mode 100644 index 000000000000..8c6535d84687 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/highlighter.css @@ -0,0 +1,96 @@ +/* +# 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/. +*/ + +:root { + --tab-highlighter: light-dark( + var(--browser-tab-highlighter, Highlight), + var(--browser-tab-highlighter, var(--dark-tab-line)) + ); + --tab-highlighter-inactive: var(--grey-10-a20); + --tab-highlighter-size: calc(var(--favicon-size) / 8); + --tab-highlighter-animation: 0.25s cubic-bezier(0.07, 0.95, 0, 1); +} + +.highlighter::before, +tab-item:not(.faviconized).subtree-collapsed.some-descendants-highlighted .highlighter::after, +tab-item:not(.faviconized).subtree-collapsed.all-descendants-highlighted .highlighter::after { + content: ""; + opacity: 0; + position: absolute; +} + +:root.animation { + .highlighter::before, + .highlighter::after { + transition: opacity var(--tab-highlighter-animation), + transform var(--tab-highlighter-animation), + width var(--tab-highlighter-animation); + } +} + +tab-item:not(.active):not(.bundled-active):not(.highlighted) tab-item-substance:hover, +tab-item:not(.active):not(.bundled-active):not(.highlighted):not(.faviconized) tab-item-substance:hover { + .highlighter::before, + .highlighter::after { + background: var(--tab-highlighter-inactive); + } +} +tab-item.active .highlighter::before, +tab-item.bundled-active .highlighter::before, +tab-item.highlighted .highlighter::before, +tab-item:not(.faviconized).bundled-active .highlighter::after, +tab-item:not(.faviconized).highlighted .highlighter::after { + background: var(--tab-highlighter); +} + +tab-item:not(.faviconized) { + .highlighter::before, + .highlighter::after { + bottom: 0; + inset-inline-start: 0; + top: 0; + transform: scaleY(0); + width: var(--tab-highlighter-size); + } +} +tab-item:not(.faviconized) tab-item-substance:hover, +tab-item.active:not(.faviconized), +tab-item.bundled-active:not(.faviconized), +tab-item.highlighted:not(.faviconized) { + .highlighter::before, + .highlighter::after { + opacity: 1; + transform: scaleY(1); + } +} + +tab-item.faviconized .highlighter::before { + height: var(--tab-highlighter-size); + inset-inline-start: 0; + inset-inline-end: 0; + top: 0; + transform: scaleX(0); +} +tab-item.faviconized tab-item-substance:hover .highlighter::before, +tab-item.active.faviconized .highlighter::before, +tab-item.bundled-active.faviconized .highlighter::before, +tab-item.highlighted.faviconized .highlighter::before { + opacity: 1; + transform: scaleX(1); +} + + +tab-item.bundled-active:not(.highlighted) .highlighter::before { + opacity: 0.4; +} + +tab-item[data-child-ids].subtree-collapsed.highlighted.some-descendants-highlighted .highlighter::after { + opacity: 0.5; +} + +tab-item[data-child-ids].subtree-collapsed.highlighted.all-descendants-highlighted .highlighter::after { + opacity: 1; +} diff --git a/waterfox/browser/components/sidebar/sidebar/styles/icons/ArrowheadDown.svg b/waterfox/browser/components/sidebar/sidebar/styles/icons/ArrowheadDown.svg new file mode 100644 index 000000000000..86d851dfc407 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/icons/ArrowheadDown.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/sidebar/styles/icons/audio-16-blocked.svg b/waterfox/browser/components/sidebar/sidebar/styles/icons/audio-16-blocked.svg new file mode 100644 index 000000000000..abc2e97375c0 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/icons/audio-16-blocked.svg @@ -0,0 +1,6 @@ + + + + diff --git a/waterfox/browser/components/sidebar/sidebar/styles/icons/audio-16-mute.svg b/waterfox/browser/components/sidebar/sidebar/styles/icons/audio-16-mute.svg new file mode 100644 index 000000000000..2dd48b5e8ad5 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/icons/audio-16-mute.svg @@ -0,0 +1,6 @@ + + + + diff --git a/waterfox/browser/components/sidebar/sidebar/styles/icons/audio-16.svg b/waterfox/browser/components/sidebar/sidebar/styles/icons/audio-16.svg new file mode 100644 index 000000000000..872c38445d1f --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/icons/audio-16.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/waterfox/browser/components/sidebar/sidebar/styles/icons/camera.svg b/waterfox/browser/components/sidebar/sidebar/styles/icons/camera.svg new file mode 100644 index 000000000000..f653b3bcc74b --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/icons/camera.svg @@ -0,0 +1,6 @@ + + + + diff --git a/waterfox/browser/components/sidebar/sidebar/styles/icons/close-16.svg b/waterfox/browser/components/sidebar/sidebar/styles/icons/close-16.svg new file mode 100644 index 000000000000..771ddb4384a2 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/icons/close-16.svg @@ -0,0 +1,6 @@ + + + + diff --git a/waterfox/browser/components/sidebar/sidebar/styles/icons/microphone.svg b/waterfox/browser/components/sidebar/sidebar/styles/icons/microphone.svg new file mode 100644 index 000000000000..183793d25c2a --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/icons/microphone.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/waterfox/browser/components/sidebar/sidebar/styles/icons/moon.svg b/waterfox/browser/components/sidebar/sidebar/styles/icons/moon.svg new file mode 100644 index 000000000000..b4ae15efab61 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/icons/moon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/waterfox/browser/components/sidebar/sidebar/styles/icons/new-16.svg b/waterfox/browser/components/sidebar/sidebar/styles/icons/new-16.svg new file mode 100644 index 000000000000..7a17e41c930a --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/icons/new-16.svg @@ -0,0 +1,6 @@ + + + + diff --git a/waterfox/browser/components/sidebar/sidebar/styles/icons/newtab-child.svg b/waterfox/browser/components/sidebar/sidebar/styles/icons/newtab-child.svg new file mode 100644 index 000000000000..ea5cd1675f9c --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/icons/newtab-child.svg @@ -0,0 +1,13 @@ + + + + + diff --git a/waterfox/browser/components/sidebar/sidebar/styles/icons/newtab-independent.svg b/waterfox/browser/components/sidebar/sidebar/styles/icons/newtab-independent.svg new file mode 100644 index 000000000000..693e846306a0 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/icons/newtab-independent.svg @@ -0,0 +1,10 @@ + + + + diff --git a/waterfox/browser/components/sidebar/sidebar/styles/icons/newtab-next-sibling.svg b/waterfox/browser/components/sidebar/sidebar/styles/icons/newtab-next-sibling.svg new file mode 100644 index 000000000000..8d9114a04ba0 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/icons/newtab-next-sibling.svg @@ -0,0 +1,13 @@ + + + + + diff --git a/waterfox/browser/components/sidebar/sidebar/styles/icons/newtab-sibling.svg b/waterfox/browser/components/sidebar/sidebar/styles/icons/newtab-sibling.svg new file mode 100644 index 000000000000..e0fdf1762bbe --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/icons/newtab-sibling.svg @@ -0,0 +1,12 @@ + + + + + diff --git a/waterfox/browser/components/sidebar/sidebar/styles/icons/pin-12.svg b/waterfox/browser/components/sidebar/sidebar/styles/icons/pin-12.svg new file mode 100644 index 000000000000..10eb79188b26 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/icons/pin-12.svg @@ -0,0 +1,4 @@ + + \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/sidebar/styles/icons/screen.svg b/waterfox/browser/components/sidebar/sidebar/styles/icons/screen.svg new file mode 100644 index 000000000000..0a6831c3532d --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/icons/screen.svg @@ -0,0 +1,7 @@ + + + + + \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/sidebar/styles/photon/base.css b/waterfox/browser/components/sidebar/sidebar/styles/photon/base.css new file mode 100644 index 000000000000..646f0314eb49 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/photon/base.css @@ -0,0 +1,215 @@ +/* +# 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/. +*/ + +:root { + --tab-vertical-padding: 0.3em; /* We should define this as a variable to reuse calculation of extra tab contents size. */ +} + +tab-item-substance, +.after-tabs button, +.after-tabs [role="button"] { + border: 1px solid var(--tab-border); + border-width: 1px 0; + padding-block: var(--tab-vertical-padding); + padding-inline: 0.25em; +} +tab-item, +.after-tabs button, +.after-tabs [role="button"] { + margin-block-end: -1px; +} + +tab-item[type="group"] tab-item-substance { + max-width: calc(max(var(--tab-label-width), calc(var(--favicon-size) / 2)) + + 0.25em /* margin-inline of tab-label */ + + 0.35em + 0.25em /* padding-inline */); +} + +tab-item[data-next-group-color] { + tab-item-substance { + --tab-surface: light-dark(var(--next-group-color-pale), var(--next-group-color)) !important; + --tab-border: light-dark(var(--next-group-color), var(--next-group-color-invert)) !important; + border: 1px solid var(--tab-border); + } +} + +tab-item.pinned tab-item-substance { + border-block-width: 0 1px; + border-inline-width: 0 1px; +} + + +.after-tabs button, +.after-tabs [role="button"] { + padding-block: 0em; + padding-inline: 0em; +} + +#tabbar + .after-tabs button, +#tabbar + .after-tabs [role="button"] { + /* IMPORTANT NOTE!! + We need to keep sizes of the .after-tabs boxes inside/outside #tabbar exact same, + to prevent flashing of the scrollbar from repeatedly switched overflow/underflow + state. See also: https://github.com/piroor/treestyletab/issues/3589 */ + /* border-block-end: none; */ + border-block-end-color: transparent; +} + + +:root.left tab-item:not(.pinned) tab-item-substance, +:root.left .after-tabs button:not([data-menu-ui]), +:root.left .after-tabs [role="button"]:not([data-menu-ui]) { + padding-inline-end: 0.35em; +} +:root.left tab-item:not(.pinned)[data-parent-id] tab-item-substance { + border-inline-start-width: 1px; +} + +:root.right tab-item:not(.pinned) tab-item-substance, +:root.right .after-tabs button:not([data-menu-ui]), +:root.right .after-tabs [role="button"]:not([data-menu-ui]) { + padding-inline-start: 0.35em; +} +:root.right tab-item:not(.pinned)[data-parent-id] tab-item-substance { + border-inline-end-width: 1px; +} + +#tabbar tab-item.pinned + tab-item:not(.pinned) tab-item-substance, +#tabbar tab-item:not(.pinned):first-of-type tab-item-substance { + border-block-start: none; +} + +:root.have-pinned-tabs #pinned-tabs-container { + border-block-end: 1px solid var(--tab-border); +} + + +/* overflow-start-indicator and overflow-end-indicator + ref: https://searchfox.org/mozilla-central/rev/1ef947827852125825dda93d8f4f83d1f55739eb/browser/themes/shared/tabs.css#527-563 */ +.overflow-indicator { + height: 0.6em; +} +.overflow-indicator.start { + background-position: 0 -0.3em; +} +.overflow-indicator.end { + background-position: 0 0.3em; + border-block-end: 1px solid var(--tab-border); +} +:root.left .overflow-indicator.start, +:root.left .overflow-indicator.end { + background-image: radial-gradient(ellipse at right, + rgba(0,0,0,0.2) 0%, + rgba(0,0,0,0.2) 7.6%, + rgba(0,0,0,0) 87.5%); +} +:root.right .overflow-indicator.start, +:root.right .overflow-indicator.end { + background-image: radial-gradient(ellipse at left, + rgba(0,0,0,0.2) 0%, + rgba(0,0,0,0.2) 7.6%, + rgba(0,0,0,0) 87.5%); +} + + +tab-item tab-label { + margin-inline-start: 0.25em; + padding-block: 0 0.25em; + padding-inline: 0; +} + +tab-item tab-favicon { + margin-block: 0.25em; +} + +tab-item { + --in-tab-button-offset: calc((var(--tab-caption-size) - 1em) / 2); + --in-tab-button-negative-offset: calc((1em - var(--tab-caption-size)) / 2); + + /* expand closebox to fill full height of tab */ + tab-closebox, + &:not(.faviconized) tab-sound-button { + margin-block: var(--in-tab-button-negative-offset); + padding-block: var(--in-tab-button-offset); + padding-inline: 0.25em; + + &::before { + border-radius: 10%; + content: ""; + display: inline-block; + height: calc(var(--svg-small-icon-size) + 0.2em); + margin-block-start: -0.1em; + margin-inline-start: -0.1em; + width: calc(var(--svg-small-icon-size) + 0.2em); + position: absolute; + } + + &:hover::before { + background: var(--tab-text); + box-shadow: 0 0 0.1em rgba(255, 255, 255, 0.3); + opacity: 0.1; + } + + &:active::before { + opacity: 0.2; + } + + &::after { + position: relative; + } + } +} + + + +tab-item.active tab-closebox:hover::before { + background: var(--tab-text); +} + +/* This is required to avoid needless padding in the scrollbox produced by the box-shadow. + See also: https://github.com/piroor/treestyletab/issues/3364 */ +#normal-tabs-container.overflow tab-item:not(.pinned).last-visible, +#normal-tabs-container.overflow tab-item:not(.pinned).last-visible ~ tab-item { + overflow: hidden; +} + + + +.sticky-tabs-container tab-item .background:not(.base) { + box-shadow: 0 0 0.4em rgba(0, 0, 0, 0.2); +} + + +/* multiselection of tabs */ + +:root { + --multiselected-color: Highlight; +} + +:root.mutiple-highlighted tab-item.highlighted tab-item-substance::after { + background: var(--multiselected-color); + bottom: 0; + content: " "; + display: block; + inset-inline-start: 0; + inset-inline-end: 0; + opacity: var(--multiselected-color-opacity); + pointer-events: none; + position: absolute; + top: 0; + z-index: 10; +} + + +/* show more-highlighter like as tree */ + +tab-item:not(.faviconized) .highlighter::after { + top: calc(var(--tab-highlighter-size) * 2); +} + +tab-item[data-child-ids].subtree-collapsed.highlighted.some-descendants-highlighted .highlighter::after { + width: calc(var(--tab-highlighter-size) * 2); +} diff --git a/waterfox/browser/components/sidebar/sidebar/styles/photon/highcontrast.css b/waterfox/browser/components/sidebar/sidebar/styles/photon/highcontrast.css new file mode 100644 index 000000000000..b18990f64cf1 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/photon/highcontrast.css @@ -0,0 +1,128 @@ +/* +# 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 url("base.css"); + +:root { + --tab-like-surface: ButtonFace; + --tab-surface-regular: var(--tab-like-surface); + --tab-surface-active: Highlight; + --tab-surface-hover: var(--tab-like-surface); + --tab-text-regular: ButtonText; + --tab-text-active: HighlightText; + --tab-text-hover: ButtonText; + --tab-text-inverted: AccentColorText; + --tab-border: GrayText; /* it should be between ButtonText and ButtonFace. */ + --tab-dropmarker: ButtonText; +} + + +/* Tab Bar */ + +:root, +#background { + background: Canvas; +} + +#background::after { + background: GrayText; + opacity: 0.5; + + bottom: 0; + content: ""; + inset-inline-start: 0; + inset-inline-end: 0; + margin-block: 0; + margin-inline: 0; + overflow: hidden; + padding-block: 0; + padding-inline: 0; + position: fixed; + top: 0; + z-index: 20; +} + + +/* Tab */ + +tab-item-substance, +.after-tabs button, +.after-tabs [role="button"] { + background: var(--tab-surface); + color: var(--tab-text); +} + +tab-item * { + color: var(--tab-text); +} + +tab-item:not(.collapsed) tab-item-substance:hover, +.after-tabs button:hover, +.after-tabs [role="button"]:hover, +#subpanel-selector-anchor:hover { + opacity: 0.7; + /*mix-blend-mode: multiply;*/ +} + +tab-item.active { + --throbber-color: HighlightText; + --tab-border: Highlight; + --tab-surface: var(--tab-surface-active); + --tab-text: var(--tab-text-active); + --tab-text-inverted: var(--tab-surface-active); +} + +tab-item:not(.active) { + --throbber-color: Highlight; +} + +.newtab-button-box { + border-color: var(--tab-border); + border-style: solid; + border-width: 1px; +} + + +/* tab-closebox */ + +tab-item tab-closebox:hover { + --tab-text: HighlightText; + opacity: 0.75; +} +tab-item tab-closebox:hover::before { + --tab-text: Highlight; + border-radius: 0; + height: calc(var(--svg-small-icon-size) + 0.3em); + margin-block-start: -0.15em; + margin-inline-start: -0.15em; + opacity: 1; + width: calc(var(--svg-small-icon-size) + 0.3em); +} + +tab-item.active tab-closebox:hover { + --tab-text: Highlight; + opacity: 0.5; +} +tab-item.active tab-closebox:hover::before { + --tab-text: HighlightText; +} + + +/* hide regular active tab marker */ +tab-item:not(.bundled-active) .highlighter::before { + display: none !important; +} + +tab-item.bundled-active:not(.highlighted) .highlighter::before { + opacity: 1; +} + + +tab-item.active tab-label { + text-decoration: underline; + text-decoration-color: transparent; + text-underline-offset: 0.25em; +} diff --git a/waterfox/browser/components/sidebar/sidebar/styles/photon/photon.css b/waterfox/browser/components/sidebar/sidebar/styles/photon/photon.css new file mode 100644 index 000000000000..003106cbd57f --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/photon/photon.css @@ -0,0 +1,191 @@ +/* +# 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 url("/resources/ui-color.css"); +@import url("base.css"); + + +/* Coloring */ + +:root { + --toolbar-non-lwt-bgcolor: var(--in-content-page-background); + --toolbar-non-lwt-textcolor: var(--in-content-page-color); + + --tab-like-surface: var(--browser-bg-base, var(--toolbar-non-lwt-bgcolor)); + --tab-surface-regular: var(--browser-bg-for-header-image, var(--tab-like-surface)); + --tab-text-regular: var(--browser-fg, var(--toolbar-non-lwt-textcolor)); + --tab-text-inverted: var(--browser-bg-more-lighter, var(--toolbar-non-lwt-bgcolor)); + --tab-text-active: var(--tab-text); + --tab-text-active-inverted: var(--toolbar-non-lwt-bgcolor); + --tab-border: var(--browser-border, var(--browser-bg-more-darker, var(--in-content-box-border-color-mixed))); + --tab-surface-hover: var(--browser-bg-hover-for-header-image, var(--browser-bg-more-lighter, var(--in-content-box-background-hover))); + --tab-surface-active: var(--browser-bg-active-for-header-image, var(--face-highlight-more-lighter, var(--in-content-button-background-mixed))); + --tab-surface-active-hover: var(--browser-bg-active-for-header-image, var(--face-highlight-more-more-lighter, var(--in-content-button-background-active-mixed))); + --tab-dropmarker: var(--browser-fg, var(--toolbar-non-lwt-textcolor)); + --throbber-color: var(--browser-loading-indicator, var(--tab-text)); + --throbber-color-active: var(--browser-loading-indicator, var(--tab-text-active)); + + --tabbar-bg: light-dark( + var(--browser-bg-darker, darkgray), + var(--browser-bg-darker, var(--dark-frame)) + ); +} + +:root, +body, +#background { + background-color: var(--browser-background, var(--tabbar-bg)); + background-image: var(--browser-bg-images, none); + background-position: var(--browser-bg-position, left); + background-size: var(--browser-bg-size, auto); + background-repeat: var(--browser-bg-repeat, none); +} +:root.right, +:root.right #background { + background-position: right; +} + +@media not (prefers-color-scheme: dark) { + :root[color-scheme="system-color"] { + --tab-surface-regular: var(--browser-bg-for-header-image, var(--browser-bg-base, -moz-dialog)); + --tab-text-regular: var(--browser-fg, -moz-dialogtext); + --tab-text-inverted: var(--browser-bg-more-lighter, -moz-dialog); + --tab-text-active: var(--tab-text-regular); + --tab-text-active-inverted: Highlight; + --tab-border: var(--browser-border, var(--browser-bg-more-darker, var(--ThreeDShadow))); + --tab-surface-hover: var(--browser-bg-hover-for-header-image, var(--browser-bg-more-lighter, var(--ThreeDHighlight))); + --tab-surface-active: var(--browser-bg-active-for-header-image, var(--face-highlight-more-lighter, Highlight)); + --tab-surface-active-hover: var(--browser-bg-active-for-header-image, var(--face-highlight-more-more-lighter, Highlight)); + --tab-dropmarker: var(--browser-fg, -moz-dialogtext); + --tabbar-bg: var(--browser-bg-darker, var(--ThreeDShadow)); + } + :root[color-scheme="system-color"] #background::after { + background: var(--browser-bg-darker,var(--AppWorkspace)); + opacity: 0.15; + + bottom: 0; + content: ""; + inset-inline-start: 0; + inset-inline-end: 0; + margin-block: 0; + margin-inline: 0; + overflow: hidden; + padding-block: 0; + padding-inline: 0; + position: fixed; + top: 0; + z-index: 20; + } +} + +#tabbar:not(.scrollbar-autohide) #pinned-tabs-container, +#tabbar:not(.scrollbar-autohide) #normal-tabs-container { + scrollbar-color: var(--browser-toolbar_text-darker, var(--theme-colors-toolbar_bottom_separator, var(--in-content-button-background-hover-mixed))) + var(--theme-colors-toolbar, var(--in-content-box-background)); +} + +tab-item, +.after-tabs button, +.after-tabs [role="button"], +#subpanel-container, +#dummy-tab-color-box { + --tab-surface: var(--tab-surface-regular); + --tab-text: var(--tab-text-regular); + --tab-text-shadow: var(--browser-textshadow-for-header-image, none); +} +tab-item-substance, +.after-tabs button, +.after-tabs [role="button"], +#subpanel-container, +#dummy-tab-color-box { + background: var(--tab-surface); + color: var(--tab-text); + text-shadow: var(--tab-text-shadow); +} + +tab-item * { + color: var(--tab-text); +} + +tab-item:not(.active) tab-item-substance:hover, +.after-tabs button:hover, +.after-tabs [role="button"]:hover { + --tab-surface: var(--tab-surface-hover); + --tab-text: var(--tab-text-regular); +} + +#subpanel-selector-anchor:hover { + background: var(--tab-surface-hover); + color: var(--tab-text-regular); +} + +tab-item.active { + --tab-surface: var(--tab-surface-active); + --throbber-color: var(--throbber-color-active); + --tab-text: var(--tab-text-active); + --tab-text-inverted: var(--tab-text-active-inverted); +} +tab-item.bundled-active, +tab-item.active tab-item-substance:hover { + --tab-surface: var(--tab-surface-active-hover); +} + +tab-closebox { + background: none transparent; + border: none 0 transparent; + margin-block: 0; + margin-inline: 0; + padding-block: 0.1em; + padding-inline: 0.1em; +} + +tab-item .burster { + --throbber-color: var(--tab-loading-fill); +} + + + +/* Dropshadow */ + +:root { + --shadow-color: rgba(0, 0, 0, 0.04); + --shadow-blur: 0.2em; +} + +:root.left tab-item:not(.pinned):not(collapsed):not(#dummy-tab) tab-item-substance { + box-shadow: -0.3em 0.3em var(--shadow-blur) var(--shadow-color); +} + +:root.right tab-item:not(.pinned):not(collapsed):not(#dummy-tab) tab-item-substance { + box-shadow: 0.3em 0.3em var(--shadow-blur) var(--shadow-color); +} + +.after-tabs button, +.after-tabs [role="button"] { + box-shadow: 0 0.3em var(--shadow-blur) var(--shadow-color); +} + + +:root.left #tabbar { + box-shadow: inset -0.2em 0.2em var(--shadow-blur) var(--shadow-color); +} + +:root.right #tabbar { + box-shadow: inset 0.2em 0.2em var(--shadow-blur) var(--shadow-color); +} + + +/* Transparent tabs are shown above solid tabs. + We have to set z-index to show all tabs in the same layer. */ +tab-item { + z-index: auto; +} + +.after-tabs button, +.after-tabs [role="button"] { + z-index: 100; +} + diff --git a/waterfox/browser/components/sidebar/sidebar/styles/proton/proton.css b/waterfox/browser/components/sidebar/sidebar/styles/proton/proton.css new file mode 100644 index 000000000000..c4516a050c88 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/proton/proton.css @@ -0,0 +1,464 @@ +/* +# 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 url("/resources/ui-color.css"); + +:root { + --color-animation: 0s ease-out; + --tab-vertical-padding: 0.45em; /* We should define this as a variable to reuse calculation of extra tab contents size. */ + --tab-border-radius-size: 0.35em; + --tab-dropshadow-blur: 0.4em; + --tab-dropshadow-padding: 0.2em; + --tab-dropshadow-size: var(--tab-dropshadow-padding); /* for backward compatibility */ + --in-tab-button-offset: calc(((var(--tab-caption-size) - 1em) / 2) + var(--tab-dropshadow-padding)); + --in-tab-button-negative-offset: calc(((1em - var(--tab-caption-size)) / 2) - var(--tab-dropshadow-padding)); + --tab-twisty-offset: calc(((var(--tab-ui-size) - var(--favicon-size)) / 2) + var(--tab-dropshadow-padding)); + --tab-twisty-negative-offset: calc(((var(--favicon-size) - var(--tab-ui-size)) / 2) - var(--tab-dropshadow-padding)); + + /* Coloring */ + + --in-content-page-background: white; + --toolbar-non-lwt-bgcolor: light-dark( + var(--in-content-page-background), + rgb(66,65,77) /* https://searchfox.org/mozilla-central/rev/0c7c41109902cb8967ec3ef2c0ddb326701cfbee/toolkit/mozapps/extensions/default-theme/manifest.json#23 */ + ); + --toolbar-non-lwt-textcolor: var(--in-content-page-color); + /* linear-gradient is from https://searchfox.org/mozilla-central/rev/6338ce9f059dbcf98072ad29033f3a4327085ddb/browser/themes/shared/tabs.inc.css#599 */ + --toolbar-non-lwt-bgimage: linear-gradient(var(--browser-selected-tab-bg, + var(--non-lwt-selected-tab-background-color-proton, + transparent)), + var(--browser-selected-tab-bg, + var(--non-lwt-selected-tab-background-color-proton, + transparent))), + linear-gradient(var(--toolbar-bgcolor, transparent), + var(--toolbar-bgcolor, transparent)), + var(--lwt-header-image, none); + + --tab-like-surface: var(--theme-colors-toolbar, var(--browser-bg-active-for-header-image, var(--browser-bg-base, var(--toolbar-non-lwt-bgcolor)))); + --tab-surface-regular: transparent; + --tab-surface-active-bgimage: var(--toolbar-non-lwt-bgimage); + --tab-text-regular: var(--browser-fg, var(--toolbar-non-lwt-textcolor)); + --tab-text-inverted: var(--browser-bg-more-lighter, var(--toolbar-non-lwt-bgcolor)); + --tab-text-active: var(--browser-fg-active, var(--toolbar-non-lwt-textcolor)); + --tab-text-active-inverted: var(--toolbar-non-lwt-bgcolor); + --tab-border: var(--browser-border, var(--browser-bg-more-darker, var(--in-content-box-border-color-mixed))); + --tab-active-border: var(--browser-bg-active-for-header-image, transparent); + --tab-surface-active: var(--browser-selected-tab-bg, var(--tab-like-surface, var(--in-content-button-background-mixed))); + --tab-dropmarker: var(--browser-fg, var(--toolbar-non-lwt-textcolor)); + --throbber-color: var(--browser-loading-indicator, var(--tab-text)); + --throbber-color-active: var(--browser-loading-indicator, var(--tab-text-active)); + + --tabbar-bg: light-dark( + -moz-dialog, + var(--browser-bg-darker, var(--dark-frame)) + ); + + --tab-highlighted-glow: light-dark( + rgb(42, 195, 162), /* https://searchfox.org/mozilla-central/rev/74f3c420ee54001059e1850bef3be876749ff873/browser/themes/shared/tabs.inc.css#927 */ + rgb(84, 255, 189) /* https://searchfox.org/mozilla-central/rev/74f3c420ee54001059e1850bef3be876749ff873/browser/themes/shared/tabs.inc.css#931 */ + ); + --multiselected-color: var(--tab-surface-active); +} +:root[data-user-agent*="Mac"] { + --non-lwt-selected-tab-background-color-proton: transparent; +} + +:root[data-user-agent*="Win"] { + --toolbar-non-lwt-textcolor: light-dark(rgb(21, 20, 26), rgb(251, 251, 254)); + --tabbar-bg: light-dark(rgb(240, 240, 244), var(--browser-bg-darker, rgb(28, 27, 34))); +} +/* Firefox does not apply pale color to tabs in inactive windows when tabs are placed not in the titlebar, so we follow the decision. */ +/* +:root[data-user-agent*="Win"]:-moz-window-inactive { + --toolbar-non-lwt-textcolor: light-dark(rgb(31, 30, 37), rgb(235, 235, 239)); + --tabbar-bg: light-dark(rgb(235, 235, 239), var(--browser-bg-darker, rgb(31, 30, 37))); +} +*/ + +:root[color-scheme="system-color"][data-user-agent*="Linux"] { + --toolbar-non-lwt-textcolor: -moz-dialogtext; + --toolbar-non-lwt-bgcolor: -moz-dialog; + --in-content-box-border-color-mixed: var(--ThreeDShadow); +} +:root[color-scheme="system-color"][data-user-agent*="Linux"] #background { + --tabbar-bg: var(--AppWorkspace); +} + +@media not (prefers-color-scheme: dark) { + :root.platform-mac { + --tabbar-bg: #f0f0f4; /* https://searchfox.org/mozilla-central/rev/e9eb869e90a8d717678c3f38bf75843e345729ab/browser/themes/osx/browser.css#52 */ + } + + /* we cannot know the GNOME3 "headerbar" color, so use inverted menu color instead. */ + :root[color-scheme="system-color"][data-user-agent*="Linux"] + tab-item:not(.active):not(.bundled-active):not(.highlighted), + :root[color-scheme="system-color"][data-user-agent*="Linux"] + .after-tabs button, + :root[color-scheme="system-color"][data-user-agent*="Linux"] + .after-tabs [role="button"], + :root[color-scheme="system-color"][data-user-agent*="Linux"] + #subpanel-selector-anchor, + :root[color-scheme="system-color"][data-user-agent*="Linux"] + #background { + --toolbar-non-lwt-bgcolor: var(--MenuText); + --toolbar-non-lwt-textcolor: var(--Menu); + --tabbar-bg: var(--toolbar-non-lwt-bgcolor); + /* these colors need to redefined here to apply new --toolbar-non-lwt-textcolor */ + --tab-text-regular: var(--browser-fg, var(--toolbar-non-lwt-textcolor)); + --tab-text-active: var(--browser-fg-active, var(--toolbar-non-lwt-textcolor)); + --tab-dropmarker: var(--browser-fg, var(--toolbar-non-lwt-textcolor)); + } +} + + +/* root container, background */ + +:root, +body, +#background { + background-color: var(--browser-background, var(--tabbar-bg)); + background-image: var(--browser-bg-images, none); + background-position: var(--browser-bg-position, left); + background-size: var(--browser-bg-size, auto); + background-repeat: var(--browser-bg-repeat, none); +} +:root.right, +:root.right #background { + background-position: right; +} + + +/* overflow-start-indicator and overflow-end-indicator + ref: https://searchfox.org/mozilla-central/rev/1ef947827852125825dda93d8f4f83d1f55739eb/browser/themes/shared/tabs.css#527-563 */ +.overflow-indicator { + height: 0.6em; + + &.start { + background-position: 0 -0.3em; + border-block-start: 0.05em solid rgba(255, 255, 255, 0.2); + } + &.end { + background-position: 0 0.3em; + border-block-end: 0.05em solid rgba(255, 255, 255, 0.2); + } + :root.left &.start, + :root.left &.end { + background-image: radial-gradient(ellipse at right, + rgba(0,0,0,0.1) 0%, + rgba(0,0,0,0.1) 7.6%, + rgba(0,0,0,0) 87.5%); + } + :root.right &.start, + :root.right &.end { + background-image: radial-gradient(ellipse at left, + rgba(0,0,0,0.1) 0%, + rgba(0,0,0,0.1) 7.6%, + rgba(0,0,0,0) 87.5%); + } +} + + +/* tab bar */ + +#tabbar:not(.scrollbar-autohide) { + #pinned-tabs-container, + #normal-tabs-container { + scrollbar-color: var(--browser-toolbar_text-darker, var(--theme-colors-toolbar_bottom_separator, var(--in-content-button-background-hover-mixed))) + var(--theme-colors-toolbar, var(--in-content-box-background)); + } +} + + +/* tab and tab-like items */ + +tab-item-substance, +.after-tabs button, +.after-tabs [role="button"], +#subpanel-selector-anchor { + border: 1px solid transparent; + border-width: 1px 0; + position: relative; +} + +tab-item, +.after-tabs button, +.after-tabs [role="button"], +#subpanel-container, +#dummy-tab-color-box { + --tab-surface: var(--tab-surface-regular); + --tab-text: var(--tab-text-regular); + --tab-text-shadow: var(--browser-textshadow-for-header-image, none); +} +tab-item-substance, +.after-tabs button, +.after-tabs [role="button"], +#subpanel-container, +#dummy-tab-color-box { + background: transparent; + color: var(--tab-text); + line-height: 1; + text-shadow: var(--tab-text-shadow); +} + +tab-item.active, +tab-item.active:hover, +tab-item.bundled-active, +tab-item.bundled-active:hover, +.mutiple-highlighted > tab-item.highlighted, +.mutiple-highlighted > tab-item.highlighted:hover { + --tab-surface: var(--tab-surface-active); + --tab-surface-bgimage: var(--tab-surface-active-bgimage); + --throbber-color: var(--throbber-color-active); + --tab-text: var(--tab-text-active); + --tab-text-inverted: var(--tab-text-active-inverted); +} + +tab-item:not(.active):not(.bundled-active):not(.highlighted):hover, +.after-tabs button:hover, +.after-tabs [role="button"]:hover, +#subpanel-selector-anchor:hover { + --tab-surface: var(--tab-text-regular); + --tab-text: var(--tab-text-regular); +} + +tab-item-substance { + padding-block: var(--tab-vertical-padding); + padding-inline: 0.25em; + /* Transparent tabs are shown above solid tabs. + We have to set z-index to show all tabs in the same layer. */ + z-index: auto; +} + +.after-tabs button, +.after-tabs [role="button"] { + --tab-like-button-padding: 0.2em; + padding-block: calc(var(--tab-dropshadow-blur) + var(--tab-like-button-padding)); + padding-inline: 0; + z-index: 100; +} + +:root.left:not(.rtl), +:root.right.rtl { + tab-item:not(.pinned) tab-item-substance, + .after-tabs button:not([data-menu-ui]), + .after-tabs [role="button"]:not([data-menu-ui]) { + padding-inline-end: 0.35em; + } +} + +:root.right:not(.rtl), +:root.left.rtl { + tab-item:not(.pinned) tab-item-substance, + .after-tabs button:not([data-menu-ui]), + .after-tabs [role="button"]:not([data-menu-ui]) { + padding-inline-start: 0.35em; + } +} + +tab-item[type="group"] tab-item-substance { + max-width: calc(max(var(--tab-label-width), calc(var(--favicon-size) / 2)) + + 0.25em /* margin-inline of tab-label */ + + var(--tab-border-radius-size) + var(--tab-border-radius-size) /* radius of tab-item-substance */ + + 0.35em + 0.25em /* padding-inline of tab-item-substance */); +} + + +/* tab background */ + +.after-tabs button::before, +.after-tabs [role="button"]::before, +#subpanel-selector-anchor::before { + content: " "; + display: inline-block; + position: absolute; +} + +tab-item .background, +.after-tabs button:hover::before, +.after-tabs [role="button"]:hover::before, +#subpanel-selector-anchor:hover::before { + background-color: var(--tab-surface); + background-image: var(--tab-surface-bgimage); + border-radius: var(--tab-border-radius-size); +} + +tab-item .background, +.after-tabs button:hover::before, +.after-tabs [role="button"]:hover::before { + bottom: var(--tab-dropshadow-padding); + inset-inline-start: var(--tab-dropshadow-padding); + inset-inline-end: var(--tab-dropshadow-padding); + top: var(--tab-dropshadow-padding); +} + +tab-item.faviconized .background { + --tab-dropshadow-padding: 0.13em; +} + +tab-item[type="group"] .background { + border: 1px solid var(--tab-border); +} + +tab-item[data-next-group-color] { + .background, + tab-item-substance:hover .background { + --tab-surface: light-dark(var(--next-group-color-pale), var(--next-group-color)) !important; + --tab-border: light-dark(var(--next-group-color), var(--next-group-color-invert)) !important; + border: 1px solid var(--tab-border); + opacity: 1 !important; + } +} + +#subpanel-selector-anchor:hover::before { + bottom: 0; + inset-inline-start: 0; + inset-inline-end: 0; + top: 0; +} + +tab-item.active, +tab-item.bundled-active, +:root.mutiple-highlighted tab-item.highlighted { + .background:not(.base), + tab-item-substance:hover .background:not(.base) { + box-shadow: 0 0 var(--tab-dropshadow-blur) rgba(0, 0, 0, 0.4); + outline: 1px solid var(--browser-tab-highlighter, var(--tab-active-border, currentcolor)); + outline-offset: -1px; + } +} +.sticky-tabs-container tab-item:not(.active):not(.bundled-active) .background.base { + box-shadow: 0 0 var(--tab-dropshadow-blur) rgba(0, 0, 0, 0.2); +} + +tab-item:not([type="group"]):not(.active):not(.bundled-active):not(.highlighted) tab-item-substance:hover .background:not(.base), +.after-tabs button:hover::before, +.after-tabs [role="button"]:hover::before, +#subpanel-selector-anchor:hover::before { + opacity: 0.11; +} + +/* extra focus ring for multiselected tabs https://bugzilla.mozilla.org/show_bug.cgi?id=1751807 */ +:root.mutiple-highlighted tab-item.highlighted { + .background:not(.base), + tab-item-substance:hover .background:not(.base) { + outline: 1px solid var(--focus-outline-color); + outline-offset: -1px; + } +} +:root.mutiple-highlighted tab-item.highlighted.active { + .background:not(.base), + tab-item-substance:hover .background:not(.base) { + outline-width: 2px; + outline-offset: -2px; + } +} + + +.sticky-tabs-container tab-item:not(.active):not(.bundled-active) .background.base { + background-color: var(--browser-background, var(--tabbar-bg, var(--bg-color, ButtonFace))); + background-image: var(--browser-bg-images, none); + background-position: var(--browser-bg-position, left); + background-size: var(--browser-bg-size, auto); + background-repeat: var(--browser-bg-repeat, none); + opacity: 1; +} + + + +/* in-tab contents */ + +tab-item { + * { + color: var(--tab-text); + } + + tab-label { + margin-inline-start: 0.25em; + padding-block: 0 0.25em; + padding-inline: 0; + } + + tab-favicon { + margin-block: 0.25em; + } + + /* expand buttons to fill full height of tab */ + tab-closebox, + &:not(.faviconized) tab-sound-button { + background: none transparent; + border: none 0 transparent; + margin-block: var(--in-tab-button-negative-offset); + margin-inline: 0; + padding-block: var(--in-tab-button-offset); + padding-inline: 0.25em; + + &::before { + border-radius: 10%; + content: ""; + display: inline-block; + height: calc(var(--svg-small-icon-size) + 0.2em); + margin-block-start: -0.1em; + margin-inline-start: -0.1em; + width: calc(var(--svg-small-icon-size) + 0.2em); + position: absolute; + } + + &:hover::before { + background: var(--tab-text); + box-shadow: 0 0 0.1em rgba(255, 255, 255, 0.3); + opacity: 0.1; + } + + &:active::before { + opacity: 0.2; + } + + &::after { + position: relative; + } + } + + &.active tab-closebox:hover::before { + background: var(--tab-text); + } + + .burster { + --throbber-color: var(--tab-loading-fill); + } +} + +/* hide active tab marker and highlighter for collapsed tree */ +.highlighter::before, +tab-item:not(.faviconized).subtree-collapsed.some-descendants-highlighted .highlighter::after, +tab-item:not(.faviconized).subtree-collapsed.all-descendants-highlighted .highlighter::after { + display: none; +} + +/* contextual identity marker in tabs */ + +tab-item:not(.faviconized) .contextual-identity-marker { + :root.left:not(.rtl) &, + :root.right.rtl & { + inset-inline-end: calc(var(--tab-dropshadow-blur) - var(--tab-highlighter-size)); + } + + :root.right:not(.rtl) &, + :root.left.rtl & { + inset-inline-start: calc(var(--tab-dropshadow-blur) - var(--tab-highlighter-size)); + } +} + +tab-item.faviconized .contextual-identity-marker { + bottom: auto; + top: 0; +} + +.after-tabs { + button.newtab-action-selector-anchor::after, + button.contextual-identities-selector-anchor::after { + margin-block-start: calc((var(--favicon-size) - var(--svg-small-icon-size)) / 2 + var(--tab-dropshadow-blur) + var(--tab-like-button-padding)); + } +} diff --git a/waterfox/browser/components/sidebar/sidebar/styles/sharing-state.css b/waterfox/browser/components/sidebar/sidebar/styles/sharing-state.css new file mode 100644 index 000000000000..14ea3c46e80e --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/sharing-state.css @@ -0,0 +1,94 @@ +@charset "UTF-8"; +/* +# 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/. +*/ + +:root { + --sharing-state-size: var(--svg-small-icon-size); + + /* https://searchfox.org/mozilla-central/rev/6b8a3f804789fb865f42af54e9d2fef9dd3ec74d/browser/themes/shared/tabs.css#284-316 */ + --sharing-state-color: rgb(224, 41, 29); + + /* https://searchfox.org/mozilla-central/rev/6b8a3f804789fb865f42af54e9d2fef9dd3ec74d/browser/themes/shared/tabs.css#271-282 */ + --tab-sharing-icon-animation: 3s linear tab-sharing-icon-pulse infinite; + --tab-sharing-icon-animation-inverted: 3s linear tab-sharing-icon-pulse-inverted infinite; +} + +@keyframes tab-sharing-icon-pulse { + 0%, 16.66%, 83.33%, 100% { + opacity: 0; + } + 33.33%, 66.66% { + opacity: 1; + } +} +@keyframes tab-sharing-icon-pulse-inverted { + 0%, 16.66%, 83.33%, 100% { + opacity: 1; + } + 33.33%, 66.66% { + opacity: 0; + } +} + +/* Show sharing status on favicons. + See also favicon.css */ + +.favicon-sharing-state { + bottom: calc((var(--favicon-size) - var(--svg-small-icon-size)) / 2); + inset-inline-start: calc((var(--favicon-size) - var(--svg-small-icon-size)) / 2); + inset-inline-end: calc((var(--favicon-size) - var(--svg-small-icon-size)) / 2); + position: absolute; + top: calc((var(--favicon-size) - var(--svg-small-icon-size)) / 2); + + tab-item.active &, + tab-item.loading &, + tab-item:not(.sharing-camera):not(.sharing-microphone):not(.sharing-screen) & { + display: none; + } + + /* The priority is: screen > camera > microphone + See also https://searchfox.org/mozilla-central/rev/85d6bf1b521040c79ed72f3966274a25a2f987c7/browser/modules/webrtcUI.sys.mjs#316-331 */ + tab-item.sharing-microphone &::before { + background-image: url("./icons/microphone.svg") + } + :root.simulate-svg-context-fill tab-item.sharing-microphone &::before { + mask-image: url("./icons/microphone.svg"); + } + + tab-item.sharing-camera &::before { + background-image: url("./icons/camera.svg") + } + :root.simulate-svg-context-fill tab-item.sharing-camera &::before { + mask-image: url("./icons/camera.svg"); + } + + tab-item.sharing-screen &::before { + background-image: url("./icons/screen.svg") + } + :root.simulate-svg-context-fill tab-item.sharing-screen &::before { + mask-image: url("./icons/screen.svg"); + } + + &::before { + fill: var(--sharing-state-color); + animation: var(--tab-sharing-icon-animation); + } + :root.simulate-svg-context-fill tab-item &::before { + /* put this here to override background-image specivied rules above! */ + background: var(--sharing-state-color); + } +} + +tab-item:not(.active):not(.loading) { + &.sharing-camera, + &.sharing-microphone, + &.sharing-screen { + .favicon-image, + .favicon-builtin::before { + animation: var(--tab-sharing-icon-animation-inverted); + } + } +} diff --git a/waterfox/browser/components/sidebar/sidebar/styles/sidebar/closetab-white.png b/waterfox/browser/components/sidebar/sidebar/styles/sidebar/closetab-white.png new file mode 100644 index 000000000000..f647118a8f3d Binary files /dev/null and b/waterfox/browser/components/sidebar/sidebar/styles/sidebar/closetab-white.png differ diff --git a/waterfox/browser/components/sidebar/sidebar/styles/sidebar/closetab.png b/waterfox/browser/components/sidebar/sidebar/styles/sidebar/closetab.png new file mode 100644 index 000000000000..7f6f03cea9ee Binary files /dev/null and b/waterfox/browser/components/sidebar/sidebar/styles/sidebar/closetab.png differ diff --git a/waterfox/browser/components/sidebar/sidebar/styles/sidebar/dropmarker.png b/waterfox/browser/components/sidebar/sidebar/styles/sidebar/dropmarker.png new file mode 100644 index 000000000000..eeb33d9a9c05 Binary files /dev/null and b/waterfox/browser/components/sidebar/sidebar/styles/sidebar/dropmarker.png differ diff --git a/waterfox/browser/components/sidebar/sidebar/styles/sidebar/sidebar.css b/waterfox/browser/components/sidebar/sidebar/styles/sidebar/sidebar.css new file mode 100644 index 000000000000..433ebd838760 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/sidebar/sidebar.css @@ -0,0 +1,366 @@ +/* ***** BEGIN LICENSE BLOCK ***** + * Version: MPL 1.1 + * + * The contents of these files are subject to the Mozilla Public License Version + * 1.1 (the "License"); you may not use these files except in compliance with + * the License. You may obtain a copy of the License at + * http://www.mozilla.org/MPL/ + * + * Software distributed under the License is distributed on an "AS IS" basis, + * WITHOUT WARRANTY OF ANY KIND, either express or implied. See the License + * for the specific language governing rights and limitations under the + * License. + * + * The Original Code is the SidebarStyleTab. + * + * The Initial Developer of the Original Code is YUKI "Piro" Hiroshi. + * Portions created by the Initial Developer are Copyright (C) 2010-2025 + * the Initial Developer. All Rights Reserved. + * + * Contributor(s): Philipp von Weitershausen + * YUKI "Piro" Hiroshi + * + * ***** END LICENSE BLOCK ***** */ + +@import url("/resources/ui-color.css"); + +@media (prefers-color-scheme: dark), not (prefers-color-scheme: dark) { + :root.sidebar, + :root:not(.sidebar) { + --bg-color: #d4dde5; + --bg-color-inactive: #e8e8e8; + --shadow-color: #404040; + --shadow-color-inactive: #868686; + --tab-like-surface: #d4dde5; + --tab-surface-regular: transparent; + --tab-surface-active-color: #94A1C0; + --tab-surface-active-color-pale: #B4C1E0; + --tab-surface-active-gradient: linear-gradient(to bottom, #A0B0CF, #7386AB) repeat-x; + --tab-surface-active-gradient-pale: linear-gradient(to bottom, #C0D0EF, #93A6CB) repeat-x; + --tab-surface-active-gradient-inactive: linear-gradient(to bottom, #B4B4B4, #8A8A8A) repeat-x; + --tab-surface-active: var(--tab-surface-active-gradient); + --tab-surface-active-hover: var(--tab-surface-active); + --tab-text-regular: black; + --tab-text-inverted: white; + --tab-text-active: white; + --badge-bg-color: #91a0c0; + --badge-color-inactive: #b5b5b5; + --badge-bg-color-inactive: #929292; + --dropmarker-border-color: #577bf3; + --dropmarker-bg-color: #d0def5; + --throbber-color: white; + --throbber-color-active: white; + --throbber-shadow-color: var(--dropmarker-border-color); + --tab-text-shadow: 0 1px var(--shadow-color); + + --tab-highlighted-highlight: white; + --tab-highlighted-glow: var(--dropmarker-border-color); + --tab-highlighted-base: var(--bg-color); + + --multiselected-color: var(--tab-surface-active-gradient); + --multiselected-color-opacity: 0.35; + + --tab-vertical-padding: 0.075em; /* We should define this as a variable to reuse calculation of extra tab contents size. */ + } + + tab-item:not(.active) { + --throbber-color: var(--dropmarker-border-color); + --throbber-shadow-color: white; + } + + /* Background colour for the tree sidebar (light blue when window is + active, grey otherwise) */ + :root, + body, + #background { + background: var(--bg-color); + } + + :root:not(.active), + :root:not(.active) #background { + background: var(--bg-color-inactive); + } + + /* Use the splitter to display the border of tab-items */ + :root.left:not(.rtl), + :root.right.rtl { + border-inline-end: 1px solid var(--shadow-color); + } + :root:not(.active).left:not(.rtl), + :root:not(.active).right.rtl { + border-inline-end-color: var(--shadow-color-inactive); + } + + :root.right:not(.rtl), + :root.left.rtl { + border-inline-start: 1px solid var(--shadow-color); + } + :root:not(.active).right:not(.rtl), + :root:not(.active).left.rtl { + border-inline-start-color: var(--shadow-color-inactive); + } +} + + +/* overflow-start-indicator and overflow-end-indicator + ref: https://searchfox.org/mozilla-central/rev/1ef947827852125825dda93d8f4f83d1f55739eb/browser/themes/shared/tabs.css#527-563 */ +.overflow-indicator { + height: 0.6em; +} +.overflow-indicator.start { + background-position: 0 -0.3em; +} +.overflow-indicator.end { + background-position: 0 0.3em; +} +:root.left .overflow-indicator.start, +:root.left .overflow-indicator.end { + background-image: radial-gradient(ellipse at right, + rgba(0,0,0,0.2) 0%, + rgba(0,0,0,0.2) 7.6%, + rgba(0,0,0,0) 87.5%); +} +:root.right .overflow-indicator.start, +:root.right .overflow-indicator.end { + background-image: radial-gradient(ellipse at left, + rgba(0,0,0,0.2) 0%, + rgba(0,0,0,0.2) 7.6%, + rgba(0,0,0,0) 87.5%); +} + + +/* Style tabs themselves. Get rid of most of the initial XUL styling */ +tab-item-substance { + border-block-start: 1px solid transparent; + min-height: 2.2em; /* height including border! */ + padding-block: var(--tab-vertical-padding) var(--tab-vertical-padding); + padding-inline: 2px 3px; + z-index: 0; +} + +tab-item[type="group"] tab-item-substance { + max-width: calc(max(var(--tab-label-width), calc(var(--favicon-size) / 2)) + + 2px + 3px /* padding-inline of tab-item-substance */ + + 0.25em /* margin-inline of tab-label */ + + 0.25em /* margin-inline of tab-label-content */); +} + +tab-item.active { + --tab-surface: var(--tab-surface-active-gradient); +} +tab-item.active tab-item-substance { + background: var(--tab-surface); + border-block-start: 1px solid var(--tab-surface-active-color); +} + +tab-item.bundled-active { + --tab-surface: var(--tab-surface-active-gradient-pale); +} +tab-item.bundled-active tab-item-substance { + background: var(--tab-surface); + border-block-start: 1px solid var(--tab-surface-active-color-pale); +} + +:root:not(.active) tab-item.active { + --tab-surface: var(--tab-surface-active-gradient-inactive); +} +:root:not(.active) tab-item.active tab-item-substance { + background: var(--tab-surface); + border-block-start: 1px solid var(--tab-surface-active-color-inactive); +} + +.sticky-tabs-container tab-item:not(.active):not(.bundled-active) .background.base { + background: var(--tabbar-bg, var(--bg-color, ButtonFace)); + box-shadow: 0 0 0.4em rgba(0, 0, 0, 0.2); +} +:root:not(.active) .sticky-tabs-container tab-item:not(.active):not(.bundled-active) .background.base { + background: var(--tabbar-bg, var(--bg-color-inactive, ButtonFace)); +} + + +tab-item[type="group"] .background { + background: var(--tab-surface); + border: 1px solid var(--tab-border); + border-radius: 0.35em; +} + +tab-item[data-next-group-color] { + .background { + --tab-surface: light-dark(var(--next-group-color-pale), var(--next-group-color)) !important; + --tab-border: light-dark(var(--next-group-color), var(--next-group-color-invert)) !important; + background: var(--tab-surface); + border: 2px solid var(--tab-border); + border-radius: 0.3em; + } +} + + +tab-item:not(.faviconized).active tab-sound-button::after { + --tab-text: white; +} + + +/* Keep the close button at a safe distance from the tab label. */ +tab-closebox::after { + display: none; +} +tab-closebox { + width: 1.5em; + min-width: 1.5em; + height: 1.5em; + margin-inline-start: 3px; + background: url("closetab.png") center center no-repeat; + opacity: 0.27; /* turn black into #b9b9b9 */ + border: 0 none; +} + +tab-closebox:active { + opacity: 0.46; /* turn black into #8a8a8a */ +} + +tab-item.active tab-closebox { + background-image: url("closetab-white.png"); + opacity: 1; +} + +/* Tab label is without special decoration except when selected: then + the text is white and bold. + We need to apply text-shadow for .label-content instead of tab-label + because transparent underline for the high contrast mode should not + have text-shadow. */ +tab-item tab-label .label-content { + font-weight: normal; + line-height: 1.4; + color: var(--tab-text); + text-align: start; + text-shadow: none; + margin-block-end: 1px; + margin-inline-start: 0.25em; + padding-block: 0 0.25em; + padding-inline: 0; +} + +tab-item.active tab-label .label-content { + font-weight: bold; + color: var(--tab-text-active); + text-shadow: var(--tab-text-shadow); +} + +tab-item.active tab-twisty { + color: white; +} + +/* Make the tab counter look like the bubbles in Mail.app et.al. */ +.counter::before, +.counter::after { + display: none; +} + +.counter { + background-color: var(--badge-bg-color); + border-radius: 0.75em; + box-sizing: content-box; + color: var(--tab-text-active); + flex-shrink: 0; + font-size: 85%; + font-weight: bold; + min-width: 1em; + padding-block: 0.25em; + padding-inline: 0.25em; + text-shadow: none; + text-align: center; +} + +tab-item.active .counter { + background-color: var(--tab-text-active); + color: var(--badge-bg-color); +} + +:root:not(.active) .counter { + background-color: var(--badge-bg-color-inactive); +} +:root:not(.active) tab-item.active .counter { + background-color: var(--tab-text-active); + color: var(--badge-color-inactive); +} + + + +/* Drag'n'drop styling */ + +/* Round blue rectangle around tabs that are dropping targets. */ +tab-item[data-drop-position="self"]:not([data-next-group-color]) tab-item-substance { + background: var(--dropmarker-bg-color); + border: 2px solid var(--dropmarker-border-color); /* not OSX's colour but Firefox's */ + border-radius: 0.3em; + padding-block: 0; + padding-inline: 8px 1px; + outline: none !important; +} + +/* White-blue-white separator for drop indicator. */ +tab-item[data-drop-position]:not([data-drop-position="self"]) tab-item-substance::before { + background: transparent !important; + max-height: 6px; + min-height: 6px; + border-image: url("dropmarker.png") 12 0 0 11 fill / 12px 0 0 11px; + border-image-outset: 6px 0 0 0; +} + +tab-item:not(.faviconized)[data-drop-position="before"] tab-item-substance::before { + top: 6px; +} + + +:root.mutiple-highlighted tab-item:not(.highlighted) tab-item-substance { + opacity: 0.5; +} + + + +/* changed from original SidebarStyleTab */ + +.after-tabs button, +.after-tabs [role="button"] { + background: transparent; + border: 0 none; +} + +/* for rightside tab bar: "display:none" breaks the order of tab contents. */ +:root + tab-item:not(.active):not(#dummy-tab) + tab-item-substance:not(:hover) + tab-closebox { + display: -moz-box !important; + visibility: collapse !important; +} + + +/* for Multiple Tab Handler */ +tab-item:not(.active)[multipletab-ready-to-close="true"] tab-item-substance:not(:hover) tab-closebox { + visibility: visible; +} + + + +/* hide regular active tab marker */ +.highlighter::before { + display: none !important; +} + + +/* multiselection of tabs */ + +:root.mutiple-highlighted tab-item.highlighted tab-item-substance::after { + background: var(--multiselected-color); + bottom: 0; + content: " "; + display: block; + inset-inline-start: 0; + inset-inline-end: 0; + opacity: var(--multiselected-color-opacity); + pointer-events: none; + position: absolute; + top: 0; + z-index: 10; +} diff --git a/waterfox/browser/components/sidebar/sidebar/styles/sound-button.css b/waterfox/browser/components/sidebar/sidebar/styles/sound-button.css new file mode 100644 index 000000000000..039dc3444ba5 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/sound-button.css @@ -0,0 +1,135 @@ +@charset "UTF-8"; +/* +# 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/. +*/ + +:root { + --sound-button-size: calc(var(--svg-small-icon-size) * 0.75); +} + +tab-sound-button { + background: none transparent; + border: none 0 transparent; + display: none; + height: calc(var(--svg-small-icon-size) + (var(--in-tab-button-offset) * 2)); + line-height: 0; + min-height: var(--svg-small-icon-size); + min-width: var(--svg-small-icon-size); /* this is required to prevent shrinking of the box which has no position:static content */ + opacity: 1; + position: relative; + z-index: var(--tab-ui-z-index); + + &::after { + -moz-context-properties: fill; + background: none no-repeat center / 100%; + content: ""; + display: inline-block; + fill: var(--tab-text); + height: var(--sound-button-size); + inset-block-start: calc((var(--svg-small-icon-size) - var(--sound-button-size)) / 2); + inset-inline-start: calc((var(--svg-small-icon-size) - var(--sound-button-size)) / 2); + position: absolute; + width: var(--sound-button-size); + } + :root.simulate-svg-context-fill &::after { + background: var(--tab-text); + mask-position: center; + mask-repeat: no-repeat; + mask-size: 100%; + } + + /* background circle */ + tab-item.faviconized & { + --sound-button-size: 10px; + inset-block-start: calc(var(--faviconized-tab-size) - (var(--sound-button-size) * 1.2)); /* cancel negative margin of the background circle */ + inset-inline-end: 0; + margin-block: 0; + margin-inline: calc(var(--sound-button-size) * 0.2 /* prevent overrapping of this button's background circle on other buttons */ + 0.2em /* and put spaces between other UIs */); + min-height: var(--sound-button-size); + min-width: var(--sound-button-size); /* this is required to prevent shrinking of the box which has no position:static content */ + padding-block: 0; + padding-inline: 0; + position: absolute; + + &::before { + background: var(--tab-text-inverted); + border-radius: 150%; + content: ""; + display: inline-block; + height: calc(var(--sound-button-size) * 1.4); + inset-inline-start: calc(0px - (var(--sound-button-size) * 0.2)); + top: calc(0px - (var(--sound-button-size) * 0.2)); + width: calc(var(--sound-button-size) * 1.4); + opacity: 0.95; + position: absolute; + } + &:hover::before { + opacity: 0.8; + } + + &::after { + inset-block-start: 0; + inset-inline-start: 0; + } + } +} + +tab-item:not(.collapsed) { + tab-sound-button:hover { + opacity: var(--button-hover-opacity); + } + + tab-sound-button:active { + opacity: var(--button-active-opacity); + } + + &.sound-playing tab-sound-button, + &.has-sound-playing-member.subtree-collapsed[data-child-ids] tab-sound-button, + &.muted tab-sound-button, + &.has-muted-member.subtree-collapsed[data-child-ids] tab-sound-button, + &.autoplay-blocked tab-sound-button, + &.has-autoplay-blocked-member.subtree-collapsed[data-child-ids] tab-sound-button { + display: inline-block; + } + + &.muted tab-sound-button::after, + &.has-muted-member.subtree-collapsed[data-child-ids] tab-sound-button::after { + background-image: url("./icons/audio-16-mute.svg"); + } + :root.simulate-svg-context-fill & { + &.muted tab-sound-button::after, + &.has-muted-member.subtree-collapsed[data-child-ids] tab-sound-button::after { + background-image: none; + mask-image: url("./icons/audio-16-mute.svg"); + } + } + + &.autoplay-blocked tab-sound-button::after, + &.has-autoplay-blocked-member.subtree-collapsed[data-child-ids] tab-sound-button::after { + background-image: url("./icons/audio-16-blocked.svg"); + } + :root.simulate-svg-context-fill & { + &.autoplay-blocked tab-sound-button::after, + &.has-autoplay-blocked-member.subtree-collapsed[data-child-ids] tab-sound-button::after { + background-image: none; + mask-image: url("./icons/audio-16-blocked.svg"); + } + } + + /* put style definition for sound-playing tab after muted tab, + because "sound-playing" is more important than muted for + mixed state tree. */ + &.sound-playing:not(.muted) tab-sound-button::after, + &.has-sound-playing-member.subtree-collapsed[data-child-ids] tab-sound-button::after { + background-image: url("./icons/audio-16.svg"); + } + :root.simulate-svg-context-fill & { + &.sound-playing:not(.muted) tab-sound-button::after, + &.has-sound-playing-member.subtree-collapsed[data-child-ids] tab-sound-button::after { + background-image: none; + mask-image: url("./icons/audio-16.svg"); + } + } +} diff --git a/waterfox/browser/components/sidebar/sidebar/styles/tab-preview.css b/waterfox/browser/components/sidebar/sidebar/styles/tab-preview.css new file mode 100644 index 000000000000..55c93d64d341 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/tab-preview.css @@ -0,0 +1,37 @@ +@charset "UTF-8"; +/* +# 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/. +*/ + +:root { + --tab-preview-reference-width: 400px; + --tab-preview-reference-height: 300px; +} + +::part(extra-contents-by-tabs-sidebar_waterfox_net container) { + text-align: center; +} + +::part(extra-contents-by-tabs-sidebar_waterfox_net frame) { + border: var(--tab-border) 1px solid; + display: inline-block; + margin-right: auto; + max-width: var(--tab-preview-size); + overflow: hidden; +} + +:root.left ::part(extra-contents-by-tabs-sidebar_waterfox_net frame) { + --tab-preview-size: calc(100% + var(--favicon-size) - var(--tab-closebox-end-offset) - 0.25em /* proton closebox padding */); +} +:root.right ::part(extra-contents-by-tabs-sidebar_waterfox_net frame) { + --tab-preview-size: calc(100% + var(--favicon-size) - var(--tab-closebox-start-offset) - 0.25em /* proton closebox padding */); +} + +::part(extra-contents-by-tabs-sidebar_waterfox_net preview) { + --tab-preview-image-size: calc(100% + var(--tab-indent)); + max-height: calc(var(--tab-preview-reference-height) / var(--tab-preview-reference-width) * var(--tab-preview-image-size)); + max-width: var(--tab-preview-image-size); + min-width: var(--tab-preview-image-size); +} diff --git a/waterfox/browser/components/sidebar/sidebar/styles/throbber.css b/waterfox/browser/components/sidebar/sidebar/styles/throbber.css new file mode 100644 index 000000000000..2f87e0080b7e --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/throbber.css @@ -0,0 +1,196 @@ +@charset "UTF-8"; +/* +# 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/. +*/ + +:root { + --throbber-color: var(--browser-loading-indicator, Highlight); + --throbber-shadow-color: transparent; + --throbber-size: var(--svg-small-icon-size); + --throbber-animation-steps: steps(30); /* The FPS is reduced from 60 to 30 at https://bugzilla.mozilla.org/show_bug.cgi?id=1511095 */ + --tab-burster-size: 5px; + --tab-burster-translation: translate(calc(var(--tab-size) / 2), calc(var(--tab-size) / 2 - var(--tab-burster-size))); +} + +tab-item.faviconized { + --tab-burster-translation: translate(calc(var(--favicon-size) / 2), calc(var(--tab-size) / 2 - var(--tab-burster-size))); +} + +@keyframes throbber { + 0% { transform: translateX(0); } + 100% { transform: translateX(-100%); } +} + + +:root:not(.blocking-throbber) #blocking-screen .throbber, +:root:not(.have-loading-tab) #tabbar .throbber, +#tabbar tab-item:not(.loading) .throbber, +#tabbar tab-item.collapsed .throbber { + display: none !important; +} + + +#blocking-screen .throbber, +tab-item:not(.collapsed) .throbber { + display: inline-block; + font-size: var(--throbber-size); + height: var(--throbber-size); + max-height: var(--throbber-size); + max-width: var(--throbber-size); + overflow: hidden; + padding-block: 0; + padding-inline: 0; + pointer-events: none; + position: relative; + width: var(--throbber-size); +} + +:root.animation:not(.throbber-synchronizing) tab-item:not(.collapsed).loading:not(.throbber-unsynchronized) .throbber::before, +:root.animation:not(.throbber-synchronizing).have-loading-tab #conductor-throbber, +:root.animation.have-loading-tab #sync-throbber, +:root.animation.blocking-throbber #blocking-screen .throbber::before { + animation: throbber 1.05s var(--throbber-animation-steps) infinite; +} + +:root.blocking-throbber #blocking-screen .throbber::before, +#tabbar tab-item:not(.collapsed).loading .throbber::before, +:root.have-loading-tab #conductor-throbber { + content: ""; + height: var(--throbber-size); + position: absolute; + width: var(--throbber-size); +} +:root.animation.blocking-throbber #blocking-screen .throbber::before, +:root.animation #tabbar tab-item:not(.collapsed).loading .throbber::before, +:root.animation.have-loading-tab #conductor-throbber { + width: calc(var(--throbber-size) * 60); +} + + +:root.blocking-throbber #blocking-screen .throbber::before, +#tabbar tab-item:not(.collapsed).loading .throbber::before { + fill: var(--throbber-color); + box-shadow: 0 0 2px var(--throbber-shadow-color); +} +:root.simulate-svg-context-fill.blocking-throbber #blocking-screen .throbber::before, +:root.simulate-svg-context-fill #tabbar tab-item:not(.collapsed).loading .throbber::before, +#tabbar tab-item:not(.collapsed).loading.throbber-unsynchronized .throbber::before { + background: var(--throbber-color); +} + +:root.blocking-throbber #blocking-screen .throbber::before, +#tabbar tab-item:not(.collapsed).loading .throbber::before { + -moz-context-properties: fill; + background: url("/resources/icons/hourglass.svg") no-repeat; +} +:root.simulate-svg-context-fill:not(.rtl) { + &.blocking-throbber #blocking-screen .throbber::before, + #tabbar tab-item:not(.collapsed).loading .throbber::before { + background-image: none; + mask: url("/resources/icons/hourglass.svg") no-repeat left center / 100%; + } +} +:root.simulate-svg-context-fill.rtl { + &.blocking-throbber #blocking-screen .throbber::before, + #tabbar tab-item:not(.collapsed).loading .throbber::before { + background-image: none; + mask: url("/resources/icons/hourglass.svg") no-repeat right center / 100%; + } +} + +:root.animation.blocking-throbber #blocking-screen .throbber::before, +:root.animation #tabbar tab-item:not(.collapsed).loading .throbber::before { + background-image: url("./throbber.svg"); +} +:root.animation.simulate-svg-context-fill.blocking-throbber #blocking-screen .throbber::before, +:root.animation.simulate-svg-context-fill #tabbar tab-item:not(.collapsed).loading .throbber::before { + background-image: none; + mask-image: url("./throbber.svg"); +} + +#tabbar tab-item:not(.collapsed).loading.throbber-unsynchronized .throbber::before { + background-image: none; +} +:root:not(.rtl) + #tabbar tab-item:not(.collapsed).loading.throbber-unsynchronized .throbber::before { + mask: -moz-element(#conductor-throbber) no-repeat left center / 100%; +} +:root.rtl + #tabbar tab-item:not(.collapsed).loading.throbber-unsynchronized .throbber::before { + mask: -moz-element(#conductor-throbber) no-repeat right center / 100%; +} +:root.simulate-svg-context-fill + #tabbar tab-item:not(.collapsed).loading.throbber-unsynchronized .throbber::before { + mask: -moz-element(#conductor-throbber-container) no-repeat; +} + +:root:not(.rtl) #conductor-throbber { + background: url("/resources/icons/hourglass.svg") no-repeat left center; +} +:root.rtl #conductor-throbber { + background: url("/resources/icons/hourglass.svg") no-repeat right center; +} +:root.animation:not(.rtl) #conductor-throbber, +:root.animation.rtl #conductor-throbber { + background-image: url("./throbber.svg"); +} + + + +:root.blocking-throbber #blocking-screen .throbber { + inset-inline-start: calc(50% - var(--throbber-size)); + position: absolute; + top: calc(50% - var(--throbber-size)); + transform: scale(2, 2); +} + +:root.blocking-throbber #blocking-screen .throbber::before { + fill: white; + box-shadow: 0 0 0.5em rgba(0, 0, 0, 0.35); +} +:root.simulate-svg-context-fill.blocking-throbber #blocking-screen .throbber::before { + background: white; +} + +#blocking-screen progress { + display: none; +} + +:root.blocking-throbber #blocking-screen progress.shown { + display: block; + --progressbar-width: calc(var(--throbber-size) * 8); + max-height: calc(var(--throbber-size) / 2); + max-width: var(--progressbar-width); + position: absolute; + top: calc(50% + var(--throbber-size)); + inset-inline-start: calc((100% - var(--progressbar-width)) / 2); +} + + + +@keyframes tab-burst-animation { + 0% { opacity: 0.4; transform: var(--tab-burster-translation) scale(1); } + 100% { opacity: 0; transform: var(--tab-burster-translation) scale(40); } +} + +@keyframes tab-burst-animation-light { + 0% { opacity: 0.2; transform: var(--tab-burster-translation) scale(1); } + 100% { opacity: 0; transform: var(--tab-burster-translation) scale(40); } +} + +:root.animation tab-item:not(.collapsed).bursting .burster::before { + animation: tab-burst-animation var(--tab-burst-duration) cubic-bezier(0, 0, 0.58, 1); + background: var(--throbber-color); + border-radius: 100%; + border: var(--tab-burster-size) solid var(--throbber-color); + content: ""; + display: inline-block; + opacity: 0; + transform-origin: 50% 50%; +} + +:root.animation tab-item:not(.collapsed).not-activated-since-load.bursting .burster::before { + animation-name: tab-burst-animation-light; +} diff --git a/waterfox/browser/components/sidebar/sidebar/styles/throbber.svg b/waterfox/browser/components/sidebar/sidebar/styles/throbber.svg new file mode 100644 index 000000000000..eb992fd77c8d --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/throbber.svg @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/waterfox/browser/components/sidebar/sidebar/styles/twisty.css b/waterfox/browser/components/sidebar/sidebar/styles/twisty.css new file mode 100644 index 000000000000..1b635f2c098a --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/styles/twisty.css @@ -0,0 +1,87 @@ +@charset "UTF-8"; +/* +# 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/. +*/ + +:root { + --tab-twisty-offset: calc((var(--tab-ui-size) - var(--favicon-size)) / 2); + --tab-twisty-negative-offset: calc((var(--favicon-size) - var(--tab-ui-size)) / 2); +} + +tab-item tab-twisty { + display: inline-block; + font-size: var(--favicon-size); + /*height: var(--favicon-size);*/ + margin-block: var(--tab-twisty-negative-offset); + margin-inline: 0; + /*max-height: var(--favicon-size);*/ + max-width: var(--favicon-size); + min-height: var(--favicon-size); + min-width: var(--favicon-size); + padding-block: var(--tab-twisty-offset); + padding-inline: 0; + position: relative; + transform-origin: 50% 50%; + white-space: pre; + width: var(--favicon-size); + z-index: var(--tab-ui-z-index); +} + +tab-item:not([data-child-ids]) tab-twisty, +tab-item.pinned tab-twisty, +tab-item.collapsed tab-twisty { + visibility: hidden !important; + pointer-events: none !important; +} + +tab-item:not(.collapsed) { + &.subtree-collapsed tab-twisty { + opacity: 0.9; + + &:hover { + opacity: 1; + } + } + + &:not(.subtree-collapsed) tab-twisty { + opacity: 0.9; + + &:hover { + opacity: 1; + } + } + + tab-twisty::before { + -moz-context-properties: fill; + background: url("./icons/ArrowheadDown.svg") no-repeat center / 60%; + content: ""; + display: inline-block; + fill: var(--tab-text); + height: var(--svg-small-icon-size); + line-height: 1; + margin-block-start: calc((var(--favicon-size) - var(--svg-small-icon-size)) / 2); + max-height: var(--favicon-size); + max-width: var(--favicon-size); + width: var(--svg-small-icon-size); + } + :root.simulate-svg-context-fill & tab-twisty::before { + background: var(--tab-text); + mask: url("./icons/ArrowheadDown.svg") no-repeat center / 60%; + } + + :root.animation & tab-twisty::before { + transition: transform 0.2s ease-out, + opacity 0.15s ease-out; + transform: rotatez(0); + } + :root.left + &.subtree-collapsed tab-twisty::before { + transform: rotatez(-90deg); + } + :root.right + &.subtree-collapsed tab-twisty::before { + transform: rotatez(90deg); + } +} diff --git a/waterfox/browser/components/sidebar/sidebar/subpanel.js b/waterfox/browser/components/sidebar/sidebar/subpanel.js new file mode 100644 index 000000000000..7fc38a858129 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/subpanel.js @@ -0,0 +1,430 @@ +/* +# 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'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; +import MenuUI from '/extlib/MenuUI.js'; + +import { + log as internalLogger, + wait, + configs, + shouldApplyAnimation, + compareAsNumber, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as Constants from '/common/constants.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TSTAPI from '/common/tst-api.js'; + +import * as BackgroundConnection from './background-connection.js'; +import * as Size from './size.js'; + +function log(...args) { + internalLogger('sidebar/subpanel', ...args); +} + +export const onResized = new EventListenerManager(); + +let mTargetWindow; +let mInitialized = false; + +const mContainer = document.querySelector('#subpanel-container'); +const mHeader = document.querySelector('#subpanel-header'); +const mSelector = document.querySelector('#subpanel-selector'); +const mSelectorAnchor = document.querySelector('#subpanel-selector-anchor'); +const mToggler = document.querySelector('#subpanel-toggler'); + +// Don't put iframe statically, because statically embedded iframe +// produces reflowing on the startup unexpectedly. +const mSubPanel = document.createElement('iframe'); +mSubPanel.setAttribute('id', 'subpanel'); +mSubPanel.setAttribute('src', 'about:blank'); + +let mHeight = 0; +let mDragStartY = 0; +let mDragStartHeight = 0; +let mProviderId = null; + +updateLayout(); + +export async function init() { + if (mInitialized) + return; + mInitialized = true; + mTargetWindow = TabsStore.getCurrentWindowId(); + + const [providerId, height] = await Promise.all([ + browser.sessions.getWindowValue(mTargetWindow, Constants.kWINDOW_STATE_SUBPANEL_PROVIDER_ID).catch(ApiTabs.createErrorHandler()), + browser.sessions.getWindowValue(mTargetWindow, Constants.kWINDOW_STATE_SUBPANEL_HEIGHT).catch(ApiTabs.createErrorHandler()) + ]); + const providerSpecificHeight = providerId && await browser.sessions.getWindowValue(mTargetWindow, `${Constants.kWINDOW_STATE_SUBPANEL_HEIGHT}:${providerId}`).catch(ApiTabs.createErrorHandler()); + mHeight = (typeof providerSpecificHeight == 'number') ? + providerSpecificHeight : + (typeof height == 'number') ? + height : + Math.max(configs.lastSubPanelHeight, 0); + + log('initialize ', { providerId, height: mHeight }); + + mContainer.appendChild(mSubPanel); + + browser.runtime.onMessage.addListener((message, _sender, _respond) => { + if (!message || + typeof message.type != 'string' || + message.type.indexOf('ws:') != 0) + return; + + //log('onMessage: ', message, sender); + switch (message.type) { + case TSTAPI.kCOMMAND_BROADCAST_API_REGISTERED: + wait(0).then(async () => { // wait until addons are updated + updateSelector(); + const provider = TSTAPI.getAddon(message.sender.id); + if (provider && + provider.subPanel && + (mProviderId == provider.id || + provider.newlyInstalled)) { + if (mHeight == 0) + mHeight = getDefaultHeight(); + await applyProvider(provider.id); + } + }); + break; + + case TSTAPI.kCOMMAND_BROADCAST_API_UNREGISTERED: + wait(0).then(() => { // wait until addons are updated + updateSelector(); + if (message.sender.id == mProviderId) + restoreLastProvider(); + }); + break; + } + }); + + log('initialize: finish '); +} + +TSTAPI.onInitialized.addListener(async () => { + await init(); + updateSelector(); + + const providerId = await browser.sessions.getWindowValue(mTargetWindow, Constants.kWINDOW_STATE_SUBPANEL_PROVIDER_ID).catch(ApiTabs.createErrorHandler()); + if (providerId) + await applyProvider(providerId); + else + restoreLastProvider(); +}); + +function getProviderIconUrl(provider) { + if (!provider.icons || typeof provider.icons != 'object') + return null; + + if ('16' in provider.icons) + return provider.icons['16']; + + const sizes = Object.keys(provider.icons, size => parseInt(size)).sort(compareAsNumber); + if (sizes.length == 0) + return null; + + // find a size most nearest to 16 + sizes.sort((a, b) => { + if (a < 16) { + if (b >= 16) + return 1; + else + return b - a; + } + if (b < 16) { + if (a >= 16) + return -1; + else + return b - a; + } + return a - b; + }); + return provider.icons[sizes[0]]; +} + +async function applyProvider(id) { + const provider = TSTAPI.getAddon(id); + log('applyProvider ', id, provider); + if (provider && + provider.subPanel) { + log('applyProvider: load ', id); + configs.lastSelectedSubPanelProviderId = mProviderId = id; + const lastHeight = await browser.sessions.getWindowValue(mTargetWindow, `${Constants.kWINDOW_STATE_SUBPANEL_HEIGHT}:${id}`).catch(ApiTabs.createErrorHandler()); + for (const item of mSelector.querySelectorAll('.radio')) { + item.classList.remove('checked'); + } + const activeItem = mSelector.querySelector(`[data-value="${id}"]`); + if (activeItem) + activeItem.classList.add('checked'); + browser.sessions.setWindowValue(mTargetWindow, Constants.kWINDOW_STATE_SUBPANEL_PROVIDER_ID, id).catch(ApiTabs.createErrorHandler()); + + const icon = mSelectorAnchor.querySelector('.icon > img'); + const iconUrl = getProviderIconUrl(provider); + if (iconUrl) + icon.src = iconUrl; + else + icon.removeAttribute('src'); + + mSelectorAnchor.querySelector('.label').textContent = provider.subPanel.title || provider.name || provider.id; + + if ('fixedHeight' in provider.subPanel) { + if (typeof provider.subPanel.fixedHeight == 'number') + mHeight = provider.subPanel.fixedHeight; + else + mHeight = Size.calc(provider.subPanel.fixedHeight); + mHeader.classList.remove('resizable'); + } + else { + mHeader.classList.add('resizable'); + if (typeof lastHeight == 'number') { + mHeight = lastHeight; + } + else if ('initialHeight' in provider.subPanel) { + if (typeof provider.subPanel.initialHeight == 'number') + mHeight = provider.subPanel.initialHeight; + else + mHeight = Size.calc(provider.subPanel.initialHeight); + } + } + + const url = new URL(provider.subPanel.url); + url.searchParams.set('windowId', mTargetWindow); + provider.subPanel.url = url.href; + + if (mHeight > 0) + load(provider.subPanel); + else + load(); + } + else { + log('applyProvider: unload missing/invalid provider ', id); + const icon = mSelectorAnchor.querySelector('.icon > img'); + icon.removeAttribute('src'); + mSelectorAnchor.querySelector('.label').textContent = ''; + mHeader.classList.add('resizable'); + load(); + } +} + +async function restoreLastProvider() { + const lastProvider = TSTAPI.getAddon(configs.lastSelectedSubPanelProviderId); + log('restoreLastProvider ', lastProvider); + if (lastProvider?.subPanel) + await applyProvider(lastProvider.id); + else if (mSelector.hasChildNodes()) + await applyProvider(mSelector.firstChild.dataset.value); + else + await applyProvider(mProviderId = null); +} + +function getDefaultHeight() { + return Math.floor(window.innerHeight * 0.5); +} + +async function load(params) { + params = params || {}; + const url = params.url || 'about:blank'; + if (url == mSubPanel.src && + url != 'about:blank') { + mSubPanel.src = 'about:blank?'; // force reload + await wait(0); + } + mSubPanel.src = url; + updateLayout(); +} + +function updateLayout() { + if (!mProviderId && !mSelector.hasChildNodes()) { + mContainer.classList.add('collapsed'); + document.documentElement.style.setProperty('--subpanel-area-size', '0px'); + } + else { + mHeight = Math.max(0, mHeight); + mContainer.classList.toggle('collapsed', mHeight == 0); + const headerSize = mHeader.offsetHeight; + const maxHeight = window.innerHeight * Math.max(0, Math.min(1, configs.maxSubPanelSizeRatio)); + const appliedHeight = Math.min(maxHeight, mHeight); + document.documentElement.style.setProperty('--subpanel-content-size', `${appliedHeight}px`); + document.documentElement.style.setProperty('--subpanel-area-size', `${appliedHeight + headerSize}px`); + + if (mHeight > 0 && + (!mSubPanel.src || mSubPanel.src == 'about:blank')) { + // delayed load + const provider = TSTAPI.getAddon(mProviderId); + if (provider?.subPanel) + mSubPanel.src = provider.subPanel.url; + } + else if (mHeight == 0) { + mSubPanel.src = 'about:blank'; + } + } + + if (!mInitialized) + return; + + onResized.dispatch(); + + if (mProviderId) { + browser.sessions.setWindowValue(mTargetWindow, Constants.kWINDOW_STATE_SUBPANEL_HEIGHT, mHeight).catch(ApiTabs.createErrorHandler()); + browser.sessions.setWindowValue(mTargetWindow, `${Constants.kWINDOW_STATE_SUBPANEL_HEIGHT}:${mProviderId}`, mHeight).catch(ApiTabs.createErrorHandler()); + } +} + +async function toggle() { + const lastEffectiveHeight = await browser.sessions.getWindowValue(mTargetWindow, Constants.kWINDOW_STATE_SUBPANEL_EFFECTIVE_HEIGHT).catch(ApiTabs.createErrorHandler()); + if (mHeight > 0) + browser.sessions.setWindowValue(mTargetWindow, Constants.kWINDOW_STATE_SUBPANEL_EFFECTIVE_HEIGHT, mHeight).catch(ApiTabs.createErrorHandler()); + mHeight = mHeight > 0 ? 0 : (lastEffectiveHeight || getDefaultHeight()); + updateLayout(); +} + +// We should save the last height only when it is changed by the user intentonally. +function saveLastHeight() { + configs.lastSubPanelHeight = mContainer.classList.contains('collapsed') ? 0 : mHeight; +} + +function isFiredOnClickable(event) { + let target = event.target; + if (!(target instanceof Element)) + target = target.parentNode; + return !!target.closest('.clickable'); +} + +function isResizable() { + const provider = mProviderId && TSTAPI.getAddon(mProviderId); + return !provider || !provider.subPanel || !('fixedHeight' in provider.subPanel); +} + +mHeader.addEventListener('mousedown', event => { + if (isFiredOnClickable(event)) + return; + event.stopPropagation(); + event.preventDefault(); + mHeader.setCapture(true); + mDragStartY = event.clientY; + mDragStartHeight = mHeight; + mHeader.addEventListener('mousemove', onMouseMove); +}); + +mHeader.addEventListener('mouseup', event => { + if (isFiredOnClickable(event)) + return; + mHeader.removeEventListener('mousemove', onMouseMove); + event.stopPropagation(); + event.preventDefault(); + document.releaseCapture(); + if (!isResizable()) + return; + mHeight = mDragStartHeight - (event.clientY - mDragStartY); + updateLayout(); + saveLastHeight(); +}); + +mHeader.addEventListener('dblclick', async event => { + if (isFiredOnClickable(event)) + return; + event.stopPropagation(); + event.preventDefault(); + await toggle(); + saveLastHeight(); +}); + +mToggler.addEventListener('click', async event => { + event.stopPropagation(); + event.preventDefault(); + await toggle(); + saveLastHeight(); +}); + +window.addEventListener('resize', _event => { + updateLayout(); +}); + +function onMouseMove(event) { + event.stopPropagation(); + event.preventDefault(); + if (!isResizable()) + return; + mHeight = mDragStartHeight - (event.clientY - mDragStartY); + updateLayout(); +} + + +function updateSelector() { + log('updateSelector start'); + const range = document.createRange(); + range.selectNodeContents(mSelector); + range.deleteContents(); + + const items = []; + for (const [id, addon] of TSTAPI.getAddons()) { + if (!addon.subPanel) + continue; + log(' subpanel provider detected: ', addon); + const item = document.createElement('li'); + item.classList.add('radio'); + item.dataset.value = id; + const iconContainer = item.appendChild(document.createElement('span')); + iconContainer.classList.add('icon'); + const icon = iconContainer.appendChild(document.createElement('img')); + const url = getProviderIconUrl(addon); + if (url) + icon.src = url; + item.appendChild(document.createTextNode(addon.subPanel.title || addon.name || id)); + items.push(item); + } + + items.sort((a, b) => a.textContent < b.textContent ? -1 : 1); + + const itemsFragment = document.createDocumentFragment(); + for (const item of items) { + itemsFragment.appendChild(item); + } + range.insertNode(itemsFragment); + range.detach(); + log('updateSelector end'); +} + +mSelector.ui = new MenuUI({ + root: mSelector, + appearance: 'panel', + onCommand: onSelect, + animationDuration: shouldApplyAnimation() ? configs.collapseDuration : 0.001 +}); + +async function onSelect(item, _event) { + if (item.dataset.value) { + await applyProvider(item.dataset.value); + saveLastHeight(); + } + mSelector.ui.close(); +} + +BackgroundConnection.onMessage.addListener(async message => { + switch (message.type) { + case Constants.kCOMMAND_TOGGLE_SUBPANEL: + toggle(); + break; + + case Constants.kCOMMAND_SWITCH_SUBPANEL: + mSelector.ui.open({ anchor: mSelectorAnchor }); + break; + + case Constants.kCOMMAND_INCREASE_SUBPANEL: + mHeight += Size.getRenderedTabHeight(); + updateLayout(); + break; + + case Constants.kCOMMAND_DECREASE_SUBPANEL: + mHeight -= Size.getRenderedTabHeight(); + updateLayout(); + break; + } +}); diff --git a/waterfox/browser/components/sidebar/sidebar/tab-context-menu.js b/waterfox/browser/components/sidebar/sidebar/tab-context-menu.js new file mode 100644 index 000000000000..8b2e65d45bc4 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/tab-context-menu.js @@ -0,0 +1,808 @@ +/* +# 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'; + +import MenuUI from '/extlib/MenuUI.js'; + +import { + log as internalLogger, + wait, + notify, + configs, + shouldApplyAnimation, + compareAsNumber, + isLinux, + isMacOS, +} from '/common/common.js'; +import * as ApiTabs from '/common/api-tabs.js'; +import * as BackgroundConnection from './background-connection.js'; +import * as Constants from '/common/constants.js'; +import * as ContextualIdentities from '/common/contextual-identities.js'; +import * as EventUtils from './event-utils.js'; +import * as Permissions from '/common/permissions.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TSTAPI from '/common/tst-api.js'; + +import { Tab, TabGroup, TreeItem } from '/common/TreeItem.js'; + +import EventListenerManager from '/extlib/EventListenerManager.js'; + +import * as TabGroupContextMenu from './tab-group-context-menu.js'; + +function log(...args) { + internalLogger('sidebar/tab-context-menu', ...args); +} + +export const onTabsClosing = new EventListenerManager(); + +let mUI; +let mMenu; + +let mNewTabButtonUI; +let mNewTabButtonMenu; + +let mContextTab = null; +let mLastOpenOptions = null; +let mIsDirty = false; + +const mExtraItems = new Map(); + +export function init() { + mMenu = document.querySelector('#tabContextMenu'); + mNewTabButtonMenu = document.querySelector('#newTabButtonContextMenu'); + document.addEventListener('contextmenu', onContextMenu, { capture: true }); + + const commonOptions = { + appearance: 'menu', + animationDuration: shouldApplyAnimation() ? configs.collapseDuration : 0.001, + subMenuOpenDelay: configs.subMenuOpenDelay, + subMenuCloseDelay: configs.subMenuCloseDelay, + }; + mUI = new MenuUI({ + ...commonOptions, + root: mMenu, + onCommand, + //onShown, + onHidden, + }); + mNewTabButtonUI = new MenuUI({ + ...commonOptions, + root: mNewTabButtonMenu, + onCommand: onNewTabButtonMenuCommand, + }); + + browser.runtime.onMessage.addListener(onMessage); + TSTAPI.onMessageExternal.addListener(onMessageExternal); + + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_GET_CONTEXT_MENU_ITEMS + }).then(items => { + importExtraItems(items); + mIsDirty = true; + }).catch(ApiTabs.createErrorSuppressor()); + + updateContextualIdentitiesSelector(); +} + +async function rebuild() { + if (!mIsDirty) + return; + + mIsDirty = false; + + const firstExtraItem = mMenu.querySelector('.extra, .imitated'); + if (firstExtraItem) { + const range = document.createRange(); + range.selectNodeContents(mMenu); + range.setStartBefore(firstExtraItem); + range.deleteContents(); + range.detach(); + } + + if (mExtraItems.size == 0) + return; + + const extraItemNodes = document.createDocumentFragment(); + const incognitoParams = { windowId: TabsStore.getCurrentWindowId() }; + for (const [id, extraItems] of mExtraItems.entries()) { + const addon = TSTAPI.getAddon(id); + if (!TSTAPI.isSafeAtIncognito(id, incognitoParams) || + !addon) + continue; + let addonItem = document.createElement('li'); + const name = getAddonName(id); + addonItem.appendChild(document.createTextNode(name)); + addonItem.setAttribute('title', name); + addonItem.classList.add('extra'); + const icon = getAddonIcon(id); + if (icon) + addonItem.dataset.icon = icon; + prepareAsSubmenu(addonItem); + + const toBeBuiltItems = []; + for (const item of extraItems) { + if (item.visible === false) + continue; + if (item.contexts && !item.contexts.includes('tab')) + continue; + if (item.documentUrlPatterns && + (!item.viewTypes || + !item.viewTypes.includes('sidebar') || + item.documentUrlPatterns.some(pattern => !/^moz-extension:/.test(pattern)) || + !matchesToPattern(location.href, item.documentUrlPatterns)) && + mContextTab && + !matchesToPattern(mContextTab.url, item.documentUrlPatterns)) + continue; + toBeBuiltItems.push(item); + } + const topLevelItems = toBeBuiltItems.filter(item => !item.parentId); + if (topLevelItems.length == 1 && + !topLevelItems[0].icons) + topLevelItems[0].icons = addon.icons || {}; + + const addonSubMenu = addonItem.lastChild; + const knownItems = {}; + for (const item of toBeBuiltItems) { + const itemNode = buildExtraItem(item, id); + if (item.parentId) { + if (item.parentId in knownItems) { + const parent = knownItems[item.parentId]; + prepareAsSubmenu(parent); + parent.lastChild.appendChild(itemNode); + } + else { + continue; + } + } + else { + addonSubMenu.appendChild(itemNode); + } + knownItems[item.id] = itemNode; + } + if (id == browser.runtime.id) { + for (const item of addonSubMenu.children) { + if (!item.nextSibling) // except the last "Tree Style Tab" menu + continue; + item.classList.remove('extra'); + item.classList.add('imitated'); + } + const range = document.createRange(); + range.selectNodeContents(addonSubMenu); + extraItemNodes.appendChild(range.extractContents()); + range.detach(); + continue; + } + switch (addonSubMenu.childNodes.length) { + case 0: + break; + case 1: + addonItem = addonSubMenu.removeChild(addonSubMenu.firstChild); + extraItemNodes.appendChild(addonItem); + default: + extraItemNodes.appendChild(addonItem); + break; + } + } + if (!extraItemNodes.hasChildNodes()) + return; + + mMenu.appendChild(extraItemNodes); +} + +function getAddonName(id) { + if (id == browser.runtime.id) + return browser.i18n.getMessage('extensionName'); + const addon = TSTAPI.getAddon(id) || {}; + return addon.name || id.replace(/@.+$/, ''); +} + +function getAddonIcon(id) { + const addon = TSTAPI.getAddon(id) || {}; + return chooseIconForAddon({ + id: id, + internalId: addon.internalId, + icons: addon.icons || {} + }); +} + +function chooseIconForAddon(params) { + const icons = params.icons || {}; + const addon = TSTAPI.getAddon(params.id) || {}; + let sizes = Object.keys(icons).map(aSize => parseInt(aSize)).sort(compareAsNumber); + const reducedSizes = sizes.filter(aSize => aSize < 16); + if (reducedSizes.length > 0) + sizes = reducedSizes; + const size = sizes[0] || null; + if (!size) + return null; + let url = icons[size]; + if (!/^\w+:\/\//.test(url)) + url = `moz-extension://${addon.internalId || params.internalId}/${url.replace(/^\//, '')}`; + return url; +} + +function prepareAsSubmenu(itemNode) { + if (itemNode.querySelector('ul')) + return itemNode; + itemNode.appendChild(document.createElement('ul')); + return itemNode; +} + +function buildExtraItem(item, ownerAddonId) { + const itemNode = document.createElement('li'); + itemNode.setAttribute('id', `${ownerAddonId}-${item.id}`); + itemNode.setAttribute('data-item-id', item.id); + itemNode.setAttribute('data-item-owner-id', ownerAddonId); + itemNode.classList.add('extra'); + itemNode.classList.add(item.type || 'normal'); + if (item.type == 'checkbox' || item.type == 'radio') { + if (item.checked) + itemNode.classList.add('checked'); + } + if (item.type != 'separator') { + itemNode.appendChild(document.createTextNode(item.title)); + itemNode.setAttribute('title', item.title); + } + itemNode.classList.toggle('disabled', item.enabled === false); + const addon = TSTAPI.getAddon(ownerAddonId) || {}; + const icon = chooseIconForAddon({ + id: ownerAddonId, + internalId: addon.internalId, + icons: item.icons || {} + }); + if (icon) + itemNode.dataset.icon = icon; + return itemNode; +} + +function matchesToPattern(url, patterns) { + if (!Array.isArray(patterns)) + patterns = [patterns]; + for (const pattern of patterns) { + if (matchPatternToRegExp(pattern).test(url)) + return true; + } + return false; +} +// https://developer.mozilla.org/en-US/Add-ons/WebExtensions/Match_patterns +const matchPattern = /^(?:(\*|http|https|file|ftp|app|moz-extension):\/\/([^\/]+|)\/?(.*))$/i; +function matchPatternToRegExp(pattern) { + if (pattern === '') + return (/^(?:https?|file|ftp|app):\/\//); + const match = matchPattern.exec(pattern); + if (!match) + throw new TypeError(`"${pattern}" is not a valid MatchPattern`); + + const [, scheme, host, path,] = match; + return new RegExp('^(?:' + + (scheme === '*' ? 'https?' : escape(scheme)) + ':\\/\\/' + + (host === '*' ? '[^\\/]*' : escape(host).replace(/^\*\./g, '(?:[^\\/]+)?')) + + (path ? (path == '*' ? '(?:\\/.*)?' : ('\\/' + escape(path).replace(/\*/g, '.*'))) : '\\/?') + + ')$'); +} + +export async function open(options = {}) { + await close(); + mLastOpenOptions = options; + mContextTab = Tab.get(options.tab?.id); + await rebuild(); + if (mIsDirty) { + return await open(options); + } + applyContext(); + const originalCanceller = options.canceller; + options.canceller = () => { + return (typeof originalCanceller == 'function' && originalCanceller()) || mIsDirty; + }; + await mUI.open(options); + if (mIsDirty) { + return await open(options); + } +} + +export async function close() { + await mUI.close(); + mMenu.removeAttribute('data-tab-id'); + mMenu.removeAttribute('data-tab-states'); + mContextTab = null; + mLastOpenOptions = null; +} + +function applyContext() { + if (mContextTab) { + mMenu.setAttribute('data-tab-id', mContextTab.id); + const states = []; + if (mContextTab.active) + states.push('active'); + if (mContextTab.pinned) + states.push('pinned'); + if (mContextTab.audible) + states.push('audible'); + if (mContextTab.$TST.muted) + states.push('muted'); + if (mContextTab.discarded) + states.push('discarded'); + if (mContextTab.incognito) + states.push('incognito'); + if (mContextTab.$TST.multiselected) + states.push('multiselected'); + mMenu.setAttribute('data-tab-states', states.join(' ')); + } +} + +async function onCommand(item, event) { + if (event.button == 1) + return; + + const contextTab = mContextTab; + wait(0).then(() => close()); // close the menu immediately! + + const id = item.getAttribute('data-item-id'); + if (!id) + return; + + const modifiers = []; + if (event.metaKey) + modifiers.push('Command'); + if (event.ctrlKey) { + modifiers.push('Ctrl'); + if (isMacOS()) + modifiers.push('MacCtrl'); + } + if (event.shiftKey) + modifiers.push('Shift'); + const owner = item.getAttribute('data-item-owner-id'); + const checked = item.matches('.radio, .checkbox:not(.checked)'); + const wasChecked = item.matches('.radio.checked, .checkbox.checked'); + const message = { + type: TSTAPI.kCONTEXT_MENU_CLICK, + info: { + checked, + editable: false, + frameUrl: null, + linkUrl: null, + mediaType: null, + menuItemId: id, + button: event.button, + modifiers: modifiers, + pageUrl: null, + parentMenuItemId: null, + selectionText: null, + srcUrl: null, + wasChecked + }, + tab: contextTab?.$TST.sanitized || contextTab, + }; + if (owner == browser.runtime.id) { + await browser.runtime.sendMessage(message).catch(ApiTabs.createErrorSuppressor()); + } + else if (TSTAPI.isSafeAtIncognito(owner, { tab: contextTab, windowId: TabsStore.getCurrentWindowId() })) { + const cache = {}; + await Promise.all([ + TSTAPI.sendMessage( + owner, + message, + { tabProperties: ['tab'], cache, isContextTab: true } + ).catch(ApiTabs.createErrorSuppressor()), + TSTAPI.sendMessage( + owner, + { + ...message, + type: TSTAPI.kFAKE_CONTEXT_MENU_CLICK + }, + { tabProperties: ['tab'], cache, isContextTab: true } + ).catch(ApiTabs.createErrorSuppressor()) + ]); + } + + if (item.matches('.checkbox')) { + item.classList.toggle('checked'); + for (const itemData of mExtraItems.get(item.dataset.itemOwnerId)) { + if (itemData.id != item.dataset.itemId) + continue; + itemData.checked = item.matches('.checked'); + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_CONTEXT_ITEM_CHECKED_STATUS_CHANGED, + id: item.dataset.itemId, + ownerId: item.dataset.itemOwnerId, + checked: itemData.checked + }).catch(ApiTabs.createErrorSuppressor()); + break; + } + mIsDirty = true; + } + else if (item.matches('.radio')) { + const currentRadioItems = new Set(); + let radioItems = null; + for (const itemData of mExtraItems.get(item.dataset.itemOwnerId)) { + if (itemData.type == 'radio') { + currentRadioItems.add(itemData); + } + else if (radioItems == currentRadioItems) { + break; + } + else { + currentRadioItems.clear(); + } + if (itemData.id == item.dataset.itemId) + radioItems = currentRadioItems; + } + if (radioItems) { + for (const itemData of radioItems) { + itemData.checked = itemData.id == item.dataset.itemId; + const radioItem = document.getElementById(`${item.dataset.itemOwnerId}-${itemData.id}`); + if (radioItem) + radioItem.classList.toggle('checked', itemData.checked); + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_CONTEXT_ITEM_CHECKED_STATUS_CHANGED, + id: item.dataset.itemId, + ownerId: item.dataset.itemOwnerId, + checked: itemData.checked + }).catch(ApiTabs.createErrorSuppressor()); + } + } + mIsDirty = true; + } +} + +async function onShown(contextTab) { + contextTab = contextTab || mContextTab + const message = { + type: TSTAPI.kCONTEXT_MENU_SHOWN, + info: { + editable: false, + frameUrl: null, + linkUrl: null, + mediaType: null, + pageUrl: null, + selectionText: null, + srcUrl: null, + contexts: ['tab'], + menuIds: [], + viewType: 'sidebar', + bookmarkId: null + }, + tab: contextTab, + windowId: TabsStore.getCurrentWindowId() + }; + const cache = {}; + const result = Promise.all([ + browser.runtime.sendMessage({ + ...message, + tab: message.tab && await TSTAPI.exportTab(message.tab, browser.runtime.id, { cache }) + }).catch(ApiTabs.createErrorSuppressor()), + TSTAPI.broadcastMessage( + message, + { tabProperties: ['tab'], cache, isContextTab: true } + ), + TSTAPI.broadcastMessage( + { + ...message, + type: TSTAPI.kFAKE_CONTEXT_MENU_SHOWN + }, + { tabProperties: ['tab'], cache, isContextTab: true } + ), + ]); + return result; +} + +async function onHidden() { + const message = { + type: TSTAPI.kCONTEXT_MENU_HIDDEN, + windowId: TabsStore.getCurrentWindowId() + }; + return Promise.all([ + browser.runtime.sendMessage(message).catch(ApiTabs.createErrorSuppressor()), + TSTAPI.broadcastMessage(message), + TSTAPI.broadcastMessage({ + ...message, + type: TSTAPI.kFAKE_CONTEXT_MENU_HIDDEN + }) + ]); +} + + +function updateContextualIdentitiesSelector() { + const disabled = document.documentElement.classList.contains('incognito') || ContextualIdentities.getCount() == 0; + + const range = document.createRange(); + range.selectNodeContents(mNewTabButtonMenu); + range.deleteContents(); + + if (disabled) + return; + + const fragment = ContextualIdentities.generateMenuItems({ + hasDefault: true, + }); + range.insertNode(fragment); + range.detach(); +} + +ContextualIdentities.onUpdated.addListener(() => { + updateContextualIdentitiesSelector(); +}); + +async function onNewTabButtonMenuCommand(item, event) { + if (item.dataset.value) { + const action = EventUtils.isAccelAction(event) ? + configs.autoAttachOnNewTabButtonMiddleClick : + configs.autoAttachOnNewTabCommand; + BackgroundConnection.sendMessage({ + type: Constants.kCOMMAND_NEW_TAB_AS, + baseTabId: Tab.getActiveTab(TabsStore.getCurrentWindowId()).id, + as: action, + cookieStoreId: item.dataset.value, + inBackground: event.shiftKey, + }); + } + + mNewTabButtonUI.close(); +} + + +function onMessage(message, _sender) { + log('tab-context-menu: internally called:', message); + switch (message.type) { + case Constants.kCOMMAND_NOTIFY_TABS_CLOSING: + // Don't respond to message for other windows, because + // the sender receives only the firstmost response. + if (message.windowId != TabsStore.getCurrentWindowId()) + return; + return Promise.resolve(onTabsClosing.dispatch(message.tabs)); + + case Constants.kCOMMAND_NOTIFY_CONTEXT_MENU_UPDATED: { + importExtraItems(message.items); + mIsDirty = true; + if (mUI.opened) + open(mLastOpenOptions); + }; break; + } +} + +function importExtraItems(importedItems) { + mExtraItems.clear(); + for (const [id, items] of Object.entries(importedItems)) { + mExtraItems.set(id, items); + } +} + +let mReservedOverrideContext = null; + +function onMessageExternal(message, sender) { + switch (message.type) { + case TSTAPI.kCONTEXT_MENU_OPEN: + case TSTAPI.kFAKE_CONTEXT_MENU_OPEN: + log('TSTAPI.kCONTEXT_MENU_OPEN:', message, { id: sender.id, url: sender.url }); + return (async () => { + const tab = message.tab ? Tab.get(message.tab) : null ; + const windowId = message.window || tab?.windowId; + if (windowId != TabsStore.getCurrentWindowId()) + return; + await onShown(tab); + await wait(25); + return open({ + tab, + left: message.left, + top: message.top + }); + })(); + + case TSTAPI.kOVERRIDE_CONTEXT: + if (message.windowId != TabsStore.getCurrentWindowId()) + return; + mReservedOverrideContext = ( + message.context == 'bookmark' ? + { context: 'bookmark', + bookmarkId: message.bookmarkId } : + message.context == 'tab' ? + { context: 'tab', + tabId: message.tabId } : + null + ); + if (mReservedOverrideContext) { + if (reserveToActivateSubpanel.reserved) { + clearTimeout(reserveToActivateSubpanel.reserved); + reserveToActivateSubpanel.reserved = null; + } + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_CONTEXT_OVERRIDDEN, + context: mReservedOverrideContext, + windowId: message.windowId, + owner: sender.id + }); + // We need to ignore mouse events on the iframe, to handle + // the contextmenu event on this parent frame side. + document.getElementById('subpanel').style.pointerEvents = 'none'; + } + break; + } +} + +function reserveToActivateSubpanel() { + if (reserveToActivateSubpanel.reserved) + clearTimeout(reserveToActivateSubpanel.reserved); + reserveToActivateSubpanel.reserved = setTimeout(() => { + reserveToActivateSubpanel.reserved = null; + document.getElementById('subpanel').style.pointerEvents = ''; + }, 100); +} +reserveToActivateSubpanel.reserved = null; + +// safe guard +window.addEventListener('mouseup', _event => { + reserveToActivateSubpanel(); +}); + +async function onContextMenu(event) { + reserveToActivateSubpanel(); + const focused = document.querySelector(':focus'); + log('onContextMenu: start ', event, focused); + + const context = mReservedOverrideContext; + mReservedOverrideContext = null; + + const openedByKeyboardOperation = ( + event.button == 0 && + !event.aleKey && + !event.ctrlKey && + !event.shiftKey && + !event.metaKey + ); + const keyboardOperationTarget = focused?.closest('[data-tab-id]') || + Tab.getActiveTab(TabsStore.getCurrentWindowId()).$TST.element; + const target = openedByKeyboardOperation ? + keyboardOperationTarget : + EventUtils.getElementTarget(event); + const originalTarget = openedByKeyboardOperation ? + keyboardOperationTarget : + EventUtils.getElementOriginalTarget(event); + const onInputField = ( + target.closest('input, textarea') || + originalTarget.closest('input, textarea') + ); + log('onContextMenu: ', { target, originalTarget, onInputField, context }); + + if (!onInputField && context?.context) { + log('onContextMenu: override context aso something given: ', context); + try { + browser.menus.overrideContext(context); + } + catch(error) { + console.log('failed to override context: ', error); + try { + if (context.context == 'bookmark' && + !(await Permissions.isGranted(Permissions.BOOKMARKS))) + notify({ + title: browser.i18n.getMessage('bookmarkContext_notification_notPermitted_title'), + message: browser.i18n.getMessage(`bookmarkContext_notification_notPermitted_message${isLinux() ? '_linux' : ''}`), + url: `moz-extension://${location.host}/options/options.html#bookmarksPermissionGranted_context` + }); + else + console.error(error); + } + catch(error) { + console.error(error); + } + } + return; + } + + console.log('notify context menu is overridden'); + browser.runtime.sendMessage({ + type: Constants.kCOMMAND_NOTIFY_CONTEXT_OVERRIDDEN, + context: null + }); + + if (onInputField) { + console.log('ignroe request on a input field'); + return; + } + + const modifierKeyPressed = isMacOS() ? event.metaKey : event.ctrlKey; + + const originalTargetBookmarkElement = originalTarget?.closest('[data-bookmark-id]'); + const bookmarkId = originalTargetBookmarkElement?.dataset.bookmarkId; + if (bookmarkId && + !modifierKeyPressed) { + log('onContextMenu: override context as bookmark context menu'); + browser.menus.overrideContext({ + context: 'bookmark', + bookmarkId: bookmarkId + }); + return; + } + + const originalTargetTreeItemElement = originalTarget?.closest('[data-tab-id]'); + const tab = originalTargetTreeItemElement ? + TabsStore.ensureLivingItem(Tab.get(parseInt(originalTargetTreeItemElement.dataset.tabId))) : + EventUtils.getTreeItemFromEvent(event); + if (tab && + !modifierKeyPressed) { + log('onContextMenu: override context as tab context menu'); + browser.menus.overrideContext({ + context: 'tab', + tabId: tab.id + }); + return; + } + + if (EventUtils.isEventFiredOnNewTabButton(event)) { + log('onContextMenu: on new tab button'); + event.stopPropagation(); + event.preventDefault(); + mNewTabButtonUI.open({ + left: event.clientX, + top: event.clientY, + }); + return; + } + + const originalTargetNativeTabGroupElement = originalTarget?.closest(`[type="${TreeItem.TYPE_GROUP}"][data-native-tab-group-id]`); + const nativeTabGroup = originalTargetNativeTabGroupElement?.$TST.group; + if (nativeTabGroup && + !modifierKeyPressed) { + log('onContextMenu: on native tab group'); + event.stopPropagation(); + event.preventDefault(); + TabGroupContextMenu.show(nativeTabGroup); + return; + } + + if (event.target == document.body) { // when the application key is pressed + log('onContextMenu: override context as tab context menu for blank area'); + browser.menus.overrideContext({ + context: 'tab', + tabId: Tab.getActiveTab(TabsStore.getCurrentWindowId()).id, + }); + return; + } + + if (!configs.emulateDefaultContextMenu) { + log('onContextMenu: no emulation'); + return; + } + + log('onContextMenu: show emulated context menu'); + event.stopPropagation(); + event.preventDefault(); + await onShown(tab); + await wait(25); + await open({ + tab, + left: event.clientX, + top: event.clientY + }); +} + +BackgroundConnection.onMessage.addListener(async message => { + switch (message.type) { + case Constants.kCOMMAND_NOTIFY_TAB_CREATED: + case Constants.kCOMMAND_NOTIFY_TAB_MOVED: + case Constants.kCOMMAND_NOTIFY_TAB_REMOVING: + case Constants.kCOMMAND_NOTIFY_TAB_ACTIVATED: + case Constants.kCOMMAND_NOTIFY_TAB_PINNED: + case Constants.kCOMMAND_NOTIFY_TAB_UNPINNED: + case Constants.kCOMMAND_NOTIFY_TAB_SHOWN: + case Constants.kCOMMAND_NOTIFY_TAB_HIDDEN: + case Constants.kCOMMAND_NOTIFY_CHILDREN_CHANGED: + close(); + mNewTabButtonUI.close(); + break; + + case Constants.kCOMMAND_SHOW_NATIVE_TAB_GROUP_MENU_PANEL: { + close(); + mNewTabButtonUI.close(); + const group = TabGroup.get(message.groupId); + Promise.race([ + group.$TST?.promisedElement, + wait(250), + ]).then(() => { + TabGroupContextMenu.show(group, true); + }); + }; break; + } +}); diff --git a/waterfox/browser/components/sidebar/sidebar/tab-group-context-menu.js b/waterfox/browser/components/sidebar/sidebar/tab-group-context-menu.js new file mode 100644 index 000000000000..a3a3c237adde --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/tab-group-context-menu.js @@ -0,0 +1,145 @@ +/* +# 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'; + +import { + configs, + log as internalLogger, +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; +import * as TabsStore from '/common/tabs-store.js'; +import { Tab } from '/common/TreeItem.js'; + +import InContentPanelController from '/resources/module/InContentPanelController.js'; +import TabGroupMenuPanel from '/resources/module/TabGroupMenuPanel.js'; // the IMPL + +function log(...args) { + internalLogger('sidebar/tab-group-context-menu', ...args); +} + +const TAB_GROUP_MENU_LABELS = Object.fromEntries(` + tabGroupMenu_tab-group-editor-title-create + tabGroupMenu_tab-group-editor-title-edit + tabGroupMenu_tab-group-editor-name-label + tabGroupMenu_tab-group-editor-name-field_placeholder + tabGroupMenu_tab-group-editor-cancel_label + tabGroupMenu_tab-group-editor-cancel_accesskey + tabGroupMenu_tab-group-editor-color-selector_aria-label + tabGroupMenu_tab-group-editor-color-selector2-blue + tabGroupMenu_tab-group-editor-color-selector2-blue_title + tabGroupMenu_tab-group-editor-color-selector2-purple + tabGroupMenu_tab-group-editor-color-selector2-purple_title + tabGroupMenu_tab-group-editor-color-selector2-cyan + tabGroupMenu_tab-group-editor-color-selector2-cyan_title + tabGroupMenu_tab-group-editor-color-selector2-orange + tabGroupMenu_tab-group-editor-color-selector2-orange_title + tabGroupMenu_tab-group-editor-color-selector2-yellow + tabGroupMenu_tab-group-editor-color-selector2-yellow_title + tabGroupMenu_tab-group-editor-color-selector2-pink + tabGroupMenu_tab-group-editor-color-selector2-pink_title + tabGroupMenu_tab-group-editor-color-selector2-green + tabGroupMenu_tab-group-editor-color-selector2-green_title + tabGroupMenu_tab-group-editor-color-selector2-gray + tabGroupMenu_tab-group-editor-color-selector2-gray_title + tabGroupMenu_tab-group-editor-color-selector2-red + tabGroupMenu_tab-group-editor-color-selector2-red_title + tabGroupMenu_tab-group-editor-action-new-tab_label + tabGroupMenu_tab-group-editor-action-new-window_label + tabGroupMenu_tab-group-editor-action-save_label + tabGroupMenu_tab-group-editor-action-ungroup_label + tabGroupMenu_tab-group-editor-action-delete_label + tabGroupMenu_tab-group-editor-done_label + tabGroupMenu_tab-group-editor-done_accesskey +`.trim().split(/\s+/).map(key => [key.replace(/-/g, '_'), browser.i18n.getMessage(key)])); +const TAB_GROUP_MENU_LABELS_CODE = JSON.stringify(TAB_GROUP_MENU_LABELS); + +const mTabGroupMenuPanel = new TabGroupMenuPanel(document.querySelector('#tabGroupContextMenuRoot'), TAB_GROUP_MENU_LABELS); +const mController = new InContentPanelController({ + type: TabGroupMenuPanel.TYPE, + logger: log, + shouldLog() { + return configs.logFor['sidebar/tab-group-context-menu'] && configs.debug; + }, + canRenderInSidebar() { + return !!(configs.tabGroupMenuPanelRenderIn & Constants.kIN_CONTENT_PANEL_RENDER_IN_SIDEBAR); + }, + canRenderInContent() { + return !!(configs.tabGroupMenuPanelRenderIn & Constants.kIN_CONTENT_PANEL_RENDER_IN_CONTENT); + }, + shouldFallbackToSidebar() { + return !!(configs.tabGroupMenuPanelRenderIn & Constants.kIN_CONTENT_PANEL_RENDER_IN_SIDEBAR); + }, + UIClass: TabGroupMenuPanel, + inSidebarUI: mTabGroupMenuPanel, + initializerCode: ` + const root = document.createElement('div'); + appendClosedContents(root); + const tabGroupMenuPanel = new TabGroupMenuPanel(root, ${TAB_GROUP_MENU_LABELS_CODE}); + + let destroy; + + const onMouseDown = event => { + if (event.target?.closest(window.closedContainerType)) { + return; + } + if (logging) + console.log('mouse down on out of tab group menu panel, destroy tab group menu container'); + browser.runtime.sendMessage({ + type: 'ws:${TabGroupMenuPanel.TYPE}:hide', + timestamp: Date.now(), + }); + destroyClosedContents(destroy); + }; + document.documentElement.addEventListener('mousedown', onMouseDown, { captuer: true }); + + destroy = createClosedContentsDestructor(tabGroupMenuPanel, () => { + document.documentElement.removeEventListener('mousedown', onMouseDown, { captuer: true }); + }); + + return tabGroupMenuPanel; + `, +}); + +export async function show(group, creating = false) { + if (!group?.id) { + return; + } + + if (!mTabGroupMenuPanel.windowId) { + const windowId = TabsStore.getCurrentWindowId(); + mTabGroupMenuPanel.windowId = windowId; + } + + mController.show({ + anchorItem: group, + targetItem: group, + messageParams: { + groupTitle: group.title, + groupColor: group.color, + creating: !!creating, + }, + }); +} + +document.querySelector('#tabbar').addEventListener('mousedown', event => { + if (event.target?.closest('#tabGroupContextMenuRoot')) { + return; + } + + const timestamp = Date.now(); + mController.sendInSidebarMessage({ + type: `ws:${TabGroupMenuPanel.TYPE}:hide`, + timestamp, + }); + + const activeTab = Tab.getActiveTab(TabsStore.getCurrentWindowId()); + if (activeTab) { + mController.sendMessage(activeTab.id, { + type: `ws:${TabGroupMenuPanel.TYPE}:hide-if-shown`, + timestamp, + }); + } +}, { capture: true }); diff --git a/waterfox/browser/components/sidebar/sidebar/tab-preview-tooltip.js b/waterfox/browser/components/sidebar/sidebar/tab-preview-tooltip.js new file mode 100644 index 000000000000..221410605f48 --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/tab-preview-tooltip.js @@ -0,0 +1,230 @@ +/* +# 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'; + +import { + configs, + log as internalLogger, +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; +import * as Permissions from '/common/permissions.js'; +import * as TabsStore from '/common/tabs-store.js'; +import { Tab, TreeItem } from '/common/TreeItem.js'; + +import InContentPanelController from '/resources/module/InContentPanelController.js'; +import TabPreviewPanel from '/resources/module/TabPreviewPanel.js'; // the IMPL + +import * as EventUtils from './event-utils.js'; +import * as Sidebar from './sidebar.js'; + +import { kEVENT_TREE_ITEM_SUBSTANCE_ENTER, kEVENT_TREE_ITEM_SUBSTANCE_LEAVE } from './components/TreeItemElement.js'; + +const CAPTURABLE_URLS_MATCHER = /^(https?|data):/; +const PREVIEW_WITH_HOST_URLS_MATCHER = /^(https?|moz-extension):/; +const PREVIEW_WITH_TITLE_URLS_MATCHER = /^file:/; + +document.addEventListener(kEVENT_TREE_ITEM_SUBSTANCE_ENTER, onTabSubstanceEnter); +document.addEventListener(kEVENT_TREE_ITEM_SUBSTANCE_LEAVE, onTabSubstanceLeave); + +function log(...args) { + internalLogger('sidebar/tab-preview-tooltip', ...args); +} + +const hoveringTabIds = new Set(); + +const mTabPreviewPanel = new TabPreviewPanel(document.querySelector('#tabPreviewRoot')); +const mController = new InContentPanelController({ + type: TabPreviewPanel.TYPE, + logger: log, + shouldLog() { + return configs.logFor['sidebar/tab-preview-tooltip'] && configs.debug; + }, + canRenderInSidebar() { + return !!(configs.tabPreviewTooltipRenderIn & Constants.kIN_CONTENT_PANEL_RENDER_IN_SIDEBAR); + }, + canRenderInContent() { + return !!(configs.tabPreviewTooltipRenderIn & Constants.kIN_CONTENT_PANEL_RENDER_IN_CONTENT); + }, + shouldFallbackToSidebar() { + return !!(configs.tabPreviewTooltipRenderIn & Constants.kIN_CONTENT_PANEL_RENDER_IN_SIDEBAR); + }, + canSendPossibleExpiredMessage(message) { + return ( + message.type != `ws:${TabPreviewPanel.TYPE}:show` || + hoveringTabIds.has(message.targetId) + ); + }, + UIClass: TabPreviewPanel, + inSidebarUI: mTabPreviewPanel, + initializerCode: ` + const root = document.createElement('div'); + appendClosedContents(root); + const tabPreviewPanel = new TabPreviewPanel(root); + + let destroy; + + const onMouseMove = () => { + if (logging) + console.log('mouse move on the content area, destroy tab preview container'); + browser.runtime.sendMessage({ + type: 'ws:${TabPreviewPanel.TYPE}:hide', + timestamp: Date.now(), + }); + destroyClosedContents(destroy); + }; + document.documentElement.addEventListener('mousemove', onMouseMove, { once: true }); + + destroy = createClosedContentsDestructor(tabPreviewPanel, () => { + window.removeEventListener('mousemove', onMouseMove); + }); + + return tabPreviewPanel; + `, +}); + +async function onTabSubstanceEnter(event) { + const timestamp = Date.now(); + + const canCaptureTab = Permissions.isGrantedSync(Permissions.ALL_URLS); + if (!canCaptureTab) + return; + + const windowId = TabsStore.getCurrentWindowId(); + const activeTab = Tab.getActiveTab(windowId) || (await browser.tabs.query({ active: true, windowId }))[0]; + + if (!configs.tabPreviewTooltip || + !(configs.tabPreviewTooltipRenderIn & Constants.kIN_CONTENT_PANEL_RENDER_IN_ANYWHERE)) {; + mController.hideIn(activeTab.id); + return; + } + + if (!event.target.tab || + event.target.tab.type != TreeItem.TYPE_TAB || + document.documentElement.classList.contains(Constants.kTABBAR_STATE_TAB_DRAGGING)) { + return; + } + + const active = event.target.tab?.id == activeTab.id; + const url = PREVIEW_WITH_HOST_URLS_MATCHER.test(event.target.tab?.url) ? new URL(event.target.tab?.url).host : + PREVIEW_WITH_TITLE_URLS_MATCHER.test(event.target.tab?.url) ? null : + event.target.tab?.url; + const hasCustomTooltip = !!event.target.hasCustomTooltip; + const hasPreview = ( + !active && + !event.target.tab?.discarded && + CAPTURABLE_URLS_MATCHER.test(event.target.tab?.url) && + !hasCustomTooltip + ); + const previewURL = ( + hasPreview && + canCaptureTab && + configs.tabPreviewTooltip && + (async () => { // We just define a getter function for now, because further operations may contain async operations and we can call this at there for more optimization. + try { + return await browser.tabs.captureTab(event.target.tab?.id); + } + catch (_error) { + } + return null; + }) + ) || null; + + if (!event.target.tab) + return; + + log(`onTabSubstanceEnter(${event.target.tab.id}}) start `, timestamp); + + hoveringTabIds.add(event.target.tab.id); + + const succeeded = await mController.show({ + anchorItem: event.target.tab, + targetItem: event.target.tab, + messageParams: { + hasCustomTooltip, + ...(hasCustomTooltip ? + { + tooltipHtml: event.target.appliedTooltipHtml, + } : + { + title: event.target.tab.title, + url, + } + ), + hasPreview, + previewURL: null, + // This is required to simulate the behavior: + // show tab preview panel with delay only when the panel is not shown yet. + waitInitialShowUntil: timestamp + Math.max(configs.tabPreviewTooltipDelayMsec, 0), + }, + promisedMessageParams: new Promise(async (resolve, _reject) => { + const promisedPreviewURL = typeof previewURL == 'function' && previewURL(); + if (!promisedPreviewURL) { + return resolve(null); + } + resolve({ + previewURL: await promisedPreviewURL, + }); + }), + canRenderInSidebar() { + return !!(configs.tabPreviewTooltipRenderIn & Constants.kIN_CONTENT_PANEL_RENDER_IN_SIDEBAR) && + !(hasCustomTooltip && configs.showCollapsedDescendantsByLegacyTooltipOnSidebar); + }, + shouldFallbackToSidebar() { + return !!(configs.tabPreviewTooltipRenderIn & Constants.kIN_CONTENT_PANEL_RENDER_IN_SIDEBAR) && + !(hasCustomTooltip && configs.showCollapsedDescendantsByLegacyTooltipOnSidebar); + }, + }); + + if (!event.target.tab) // the tab may be destroyied while we capturing tab preview + return; + + if (event.target.tab.$TST.element && + succeeded) + event.target.tab.$TST.element.invalidateTooltip(); +} +onTabSubstanceEnter = EventUtils.wrapWithErrorHandler(onTabSubstanceEnter); + +async function onTabSubstanceLeave(event) { + const timestamp = Date.now(); + if (!event.target.tab) + return; + + hoveringTabIds.delete(event.target.tab.id); + + if (!event.target.tab) // the tab was closed while waiting + return; + + mController.hide({ targetItem: event.target.tab, timestamp }); +} +onTabSubstanceLeave = EventUtils.wrapWithErrorHandler(onTabSubstanceLeave); + +Sidebar.onReady.addListener(() => { + const windowId = TabsStore.getCurrentWindowId(); + mTabPreviewPanel.windowId = windowId; +}); + +function hideOnUserAction(timestamp) { + hoveringTabIds.clear(); + + mController.hideInSidebar({ timestamp }); + + const activeTab = Tab.getActiveTab(TabsStore.getCurrentWindowId()); + if (activeTab) { + mController.hide({ timestamp }); + } +} + +document.querySelector('#tabbar').addEventListener('mouseleave', async () => { + const timestamp = Date.now(); + log('mouse is left from the tab bar ', timestamp); + hideOnUserAction(timestamp); +}); + +document.querySelector('#tabbar').addEventListener('dragover', async () => { + const timestamp = Date.now(); + log('mouse is dragover on the tab bar ', timestamp); + hideOnUserAction(timestamp); +}); diff --git a/waterfox/browser/components/sidebar/sidebar/tab-preview.js b/waterfox/browser/components/sidebar/sidebar/tab-preview.js new file mode 100644 index 000000000000..afdb670399be --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/tab-preview.js @@ -0,0 +1,53 @@ +/* +# 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'; + +import { + wait, +} from '/common/common.js'; +import * as TabsStore from '/common/tabs-store.js'; + +let mLastHoverTabId = null; + +document.querySelector('#tabbar').addEventListener('mouseenter', async event => { + if (event.target.localName != 'tab-item-substance') + return; + + const tab = event.target.closest('tab-item').apiRaw; + + mLastHoverTabId = tab.id; + + if (mLastHoverTabId != tab.id || + tab.active) + return; + + browser.waterfoxBridge.showPreviewPanel( + tab.id, + Math.round(event.target.getBoundingClientRect().top) + ); +}, { capture: true }); + +document.querySelector('#tabbar').addEventListener('mouseleave', async event => { + const windowId = TabsStore.getCurrentWindowId(); + if (event.target == event.currentTarget && + windowId) { + browser.waterfoxBridge.hidePreviewPanel(windowId); // clear for safety + return; + } + + if (event.target.localName != 'tab-item-substance') + return; + + const tab = event.target.closest('tab-item').apiRaw; + + await wait(0); + + if (mLastHoverTabId != tab.id) + return; + + mLastHoverTabId = null; + browser.waterfoxBridge.hidePreviewPanel(tab.windowId); +}, { capture: true }); diff --git a/waterfox/browser/components/sidebar/sidebar/tst-api-frontend.js b/waterfox/browser/components/sidebar/sidebar/tst-api-frontend.js new file mode 100644 index 000000000000..f16fddb81dcc --- /dev/null +++ b/waterfox/browser/components/sidebar/sidebar/tst-api-frontend.js @@ -0,0 +1,1032 @@ +/* +# 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'; + +import { DOMUpdater } from '/extlib/dom-updater.js'; + +import { + configs, + log as internalLogger, +} from '/common/common.js'; +import * as Constants from '/common/constants.js'; +import * as TabsStore from '/common/tabs-store.js'; +import * as TSTAPI from '/common/tst-api.js'; + +import { Tab } from '/common/TreeItem.js'; + +import * as EventUtils from './event-utils.js'; +import * as Sidebar from './sidebar.js'; +import * as SidebarItems from './sidebar-items.js'; +import * as Size from './size.js'; + +import { + kTREE_ITEM_ELEMENT_NAME, +} from './components/TreeItemElement.js'; + +function log(...args) { + internalLogger('sidebar/tst-api-frontend', ...args); +} + +// Above/below extra contents need to be inserted here, because missing those +// contents will shrink height of the tab and may triggers "underflow" of the +// tab bar unexpectedly. +const AUTO_REINSERT_PLACES = new Set([ + 'tab-above', + 'tab-below', +]); + +let mTargetWindow; + +Sidebar.onInit.addListener(() => { + mTargetWindow = TabsStore.getCurrentWindowId(); +}); + +SidebarItems.onReuseTreeItemElement.addListener(tabElement => { + setExtraTabContentsToElement(tabElement, '*', { place: 'tab-indent' }); + setExtraTabContentsToElement(tabElement, '*', { place: 'tab-front' }); + setExtraTabContentsToElement(tabElement, '*', { place: 'tab-behind' }); + setExtraTabContentsToElement(tabElement, '*', { place: 'tab-above' }); + setExtraTabContentsToElement(tabElement, '*', { place: 'tab-below' }); +}); + +const mAddonsWithExtraContents = new Set(); + +const mNewTabButtonExtraItemsContainerRoots = Array.from( + document.querySelectorAll(`.${Constants.kNEWTAB_BUTTON} .${Constants.kEXTRA_ITEMS_CONTAINER}`), + container => { + const root = container.attachShadow({ mode: 'open' }); + root.itemById = new Map(); + return root; + } +); + +const mTabbarTopExtraItemsContainerRoot = (() => { + const container = document.querySelector(`#tabbar-top > .${Constants.kEXTRA_ITEMS_CONTAINER}`); + const root = container.attachShadow({ mode: 'open' }); + root.itemById = new Map(); + return root; +})(); + +const mTabbarBottomExtraItemsContainerRoot = (() => { + const container = document.querySelector(`#tabbar-bottom > .${Constants.kEXTRA_ITEMS_CONTAINER}`); + const root = container.attachShadow({ mode: 'open' }); + root.itemById = new Map(); + return root; +})(); + +const mDummyTab = document.getElementById('dummy-tab'); + +TSTAPI.onRegistered.addListener(addon => { + // Install stylesheet always, even if the addon is not allowed to access + // private windows, because the client addon can be alloed on private + // windows by Firefox itself and extra context menu commands may be called + // via Firefox's native context menu (or shortcuts). + if (addon.style) + installStyle(addon.id, addon.style); +}); + +TSTAPI.onUnregistered.addListener(addon => { + clearAllExtraTabContents(addon.id); + uninstallStyle(addon.id) +}); + +TSTAPI.onMessageExternal.addListener((message, sender) => { + if ((!configs.incognitoAllowedExternalAddons.includes(sender.id) && + document.documentElement.classList.contains('incognito'))) + return; + + if (!message.windowId) + message.windowId = message.window || mTargetWindow; + + if (message.windowId != mTargetWindow) + return; + + switch (message.type) { + case TSTAPI.kCLEAR_ALL_EXTRA_TAB_CONTENTS: // for backward compatibility + clearAllExtraTabContents(sender.id); + return; + + case TSTAPI.kSET_EXTRA_NEW_TAB_BUTTON_CONTENTS: // for backward compatibility + setExtraNewTabButtonContents(sender.id, message); + return; + + case TSTAPI.kCLEAR_EXTRA_NEW_TAB_BUTTON_CONTENTS: // for backward compatibility + clearExtraNewTabButtonContents(sender.id); + return; + + case TSTAPI.kSET_EXTRA_CONTENTS: + log('setting contents: ', message, sender.id); + switch (String(message.place).toLowerCase()) { + case 'newtabbutton': + case 'new-tab-button': + case 'newtab-button': + setExtraNewTabButtonContents(sender.id, message); + return; + + case 'tabbar-top': + setExtraTabbarTopContents(sender.id, message); + return; + + case 'tabbar-bottom': + setExtraTabbarBottomContents(sender.id, message); + return; + + default: // tabs + TSTAPI.getTargetTabs(message, sender).then(tabs => { + log(' => setting contents in tabs: ', tabs, message); + for (const tab of tabs) { + setExtraContentsTo(tab, sender.id, message); + } + }); + return; + } + return; + + case TSTAPI.kCLEAR_EXTRA_CONTENTS: + log('clearing contents: ', message, sender.id); + switch (String(message.place).toLowerCase()) { + case 'newtabbutton': + case 'new-tab-button': + case 'newtab-button': + clearExtraNewTabButtonContents(sender.id); + return; + + case 'tabbar-top': + clearExtraTabbarTopContents(sender.id); + return; + + case 'tabbar-bottom': + clearExtraTabbarBottomContents(sender.id); + return; + + default: // tabs + TSTAPI.getTargetTabs(message, sender).then(tabs => { + log(' => clearing contents in tabs: ', tabs, message); + for (const tab of tabs) { + clearExtraTabContentsIn(tab, sender.id); + } + }); + return; + } + return; + + case TSTAPI.kCLEAR_ALL_EXTRA_CONTENTS: + log('clearing all contents: ', sender.id); + clearAllExtraTabContents(sender.id); + return; + + case TSTAPI.kSET_EXTRA_CONTENTS_PROPERTIES: + log('setting properties for contents: ', message, sender.id); + TSTAPI.getTargetTabs(message, sender).then(tabs => { + log(' => setting properties for contents in tabs: ', tabs, message); + setExtraContentsProperties({ + id: sender.id, + tabs, + place: message.place || null, + part: message.part, + properties: message.properties || {}, + }); + }); + return; + + case TSTAPI.kFOCUS_TO_EXTRA_CONTENTS: + log('focus to contents: ', message, sender.id); + TSTAPI.getTargetTabs(message, sender).then(tabs => { + log(' => focus to contents in tabs: ', tabs, message); + focusToExtraContents({ + id: sender.id, + tabs, + place: message.place || null, + part: message.part, + }); + }); + return; + + default: + Tab.waitUntilTracked(message.id).then(() => { + const tabElement = document.querySelector(`#tab-${message.id}`); + if (!tabElement) + return; + + switch (message.type) { + case TSTAPI.kSET_EXTRA_TAB_CONTENTS: // for backward compatibility + setExtraTabContentsToElement(tabElement, sender.id, message); + break; + + case TSTAPI.kCLEAR_EXTRA_TAB_CONTENTS: // for backward compatibility + clearExtraTabContentsInElement(tabElement, sender.id); + break; + } + }); + return; + } +}); + +// https://developer.mozilla.org/docs/Web/HTML/Element +const SAFE_CONTENTS = ` +a +abbr +acronym +address +//applet +area +article +aside +b +//base +//basefont +bdi +bdo +//bgsound +big +blink +blockquote +//body +br +button +canvas +caption +center +cite +code +col +colgroup +command +//content +data +datalist +dd +del +details +dfn +dialog +dir +div +dl +dt +//element +em +//embed +fieldset +figcaption +figure +font +footer +//form +//frame +//frameset +h1 +//head +header +hgroup +hr +//html +i +//iframe +image +img +input +ins +isindex +kbd +keygen +label +legend +li +//link +listing +main +map +mark +marquee +menu +menuitem +//meta +//meter +multicol +nav +nextid +nobr +//noembed +//noframes +//noscript +object +ol +optgroup +option +output +p +param +picture +plaintext +pre +progress +q +rb +rp +rt +rtc +duby +s +samp +//script +section +select +//shadow +slot +small +source +spacer +span +strike +strong +//style +sub +summary +sup +table +tbody +td +template +textarea +tfoot +th +thead +time +//title +tr +track +tt +u +ul +var +//video +wbr +xmp +`.trim().split('\n').filter(selector => !selector.startsWith('//')); +const DANGEROUS_CONTENTS_SELECTOR = SAFE_CONTENTS.map(selector => `:not(${selector})`).join(''); + +export function setExtraContentsTo(tab, id, params = {}) { + if (!tab || !tab.$TST.element) + return; + if (typeof id != 'string') { // the addon id is optional + params = id; + id = browser.runtime.id; + } + setExtraTabContentsToElement(tab.$TST.element, id, params); +} + +function setExtraContentsToContainer(container, id, params = {}) { + if (id == '*') { + for (const id of container.itemById.keys()) { + setExtraContentsToContainer(container, id, params = {}); + } + return; + } + + if (typeof id != 'string') { // the addon id is optional + params = id; + id = browser.runtime.id; + } + + let cacheHolder, cacheKey; + const place = String(params.place).toLowerCase(); + if (AUTO_REINSERT_PLACES.has(place)) { + const cacheHolderElement = container?.host?.closest(kTREE_ITEM_ELEMENT_NAME) || container; + cacheHolder = cacheHolderElement.$TST || cacheHolderElement; + cacheKey = `$$lastContentsSourceFor_${id}`; + params = { + ...params, + contents: ( + params.contents || + (!('contents' in params) && cacheHolder[cacheKey]) || + null + ), + }; + switch (place) { + case 'tab-above': + onExtraContentsAboveChanged(id, params); + break; + + case 'tab-below': + onExtraContentsBelowChanged(id, params); + break; + } + } + + let item = container.itemById.get(id); + if (!params.style && + item && + item.styleElement && + item.styleElement.parentNode) { + container.removeChild(item.styleElement); + item.styleElement = null; + } + if (!params.contents) { + if (item) { + if (item.styleElement) + container.removeChild(item.styleElement); + container.removeChild(item); + container.itemById.delete(id); + } + if (cacheHolder) + cacheHolder[cacheKey] = null; + return; + } + + const extraContentsPartName = getExtraContentsPartName(id); + + if (!item) { + item = document.createElement('span'); + item.setAttribute('part', `${extraContentsPartName} container`); + item.classList.add('extra-item'); + item.classList.add(extraContentsPartName); + item.dataset.owner = id; + container.itemById.set(id, item); + } + if ('style' in params && !item.styleElement) { + const style = document.createElement('style'); + style.setAttribute('type', 'text/css'); + item.styleElement = style; + } + + const contentsSource = String(params.contents || '').trim(); + if (cacheHolder) + cacheHolder[cacheKey] = contentsSource; + + const range = document.createRange(); + range.selectNodeContents(item); + const contents = range.createContextualFragment(contentsSource); + range.detach(); + + const dangerousContents = contents.querySelectorAll(DANGEROUS_CONTENTS_SELECTOR); + for (const node of dangerousContents) { + node.parentNode.removeChild(node); + } + if (dangerousContents.length > 0) + console.log(`Could not include some elements as extra contents. provider=${id}, container:`, container, dangerousContents); + + // Sanitize remote resources + for (const node of contents.querySelectorAll('*[href], *[src], *[srcset], *[part]')) { + for (const attribute of node.attributes) { + if (attribute.name == 'part') + attribute.value += ` ${extraContentsPartName}`; + if (/^(href|src|srcset)$/.test(attribute.name) && + attribute.value && + !/^(data|resource|chrome|about|moz-extension):/.test(attribute.value)) { + attribute.value = '#'; + node.setAttribute('part', `${node.getAttribute('part') || ''} sanitized`); + } + } + } + // We don't need to handle inline event handlers because + // they are blocked by the CSP mechanism. + + if ('style' in params) + item.styleElement.textContent = (params.style || '') + .replace(/%EXTRA_CONTENTS_PART%/gi, `${extraContentsPartName}`); + + DOMUpdater.update(item, contents); + + if (item.styleElement && + !item.styleElement.parentNode) + container.appendChild(item.styleElement); + if (!item.parentNode) + container.appendChild(item); + + mAddonsWithExtraContents.add(id); +} + +function getExtraContentsPartName(id) { + if (!id) // the addon id is optional + id = browser.runtime.id; + return `extra-contents-by-${id.replace(/[^-a-z0-9_]/gi, '_')}`; +} + + +function setExtraTabContentsToElement(tabElement, id, params = {}) { + if (typeof id != 'string') { // the addon id is optional + params = id; + id = browser.runtime.id; + } + let container; + switch (String(params.place).toLowerCase()) { + case 'indent': // for backward compatibility + case 'tab-indent': + container = tabElement.extraItemsContainerIndentRoot; + break; + + case 'behind': // for backward compatibility + case 'tab-behind': + container = tabElement.extraItemsContainerBehindRoot; + break; + + case 'front': // for backward compatibility + case 'tab-front': + default: + container = tabElement.extraItemsContainerFrontRoot; + break; + + case 'tab-above': + container = tabElement.extraItemsContainerAboveRoot; + break; + + case 'tab-below': + container = tabElement.extraItemsContainerBelowRoot; + break; + } + + if (container) + return setExtraContentsToContainer(container, id, params); +} + +function onExtraContentsAboveChanged(id, params) { + if (typeof id != 'string') { // the addon id is optional + params = id; + id = browser.runtime.id; + } + if (onExtraContentsAboveChanged.invoked) + return; + onExtraContentsAboveChanged.invoked = true; + window.requestAnimationFrame(() => { + onExtraContentsAboveChanged.invoked = false; + if (params.container != mDummyTab.extraItemsContainerAboveRoot) { + setExtraContentsToContainer(mDummyTab.extraItemsContainerAboveRoot, id, { + ...params, + container: mDummyTab.extraItemsContainerAboveRoot, + }); + } + throttledUpdateSize(); + }); +} + +function onExtraContentsBelowChanged(id, params) { + if (typeof id != 'string') { // the addon id is optional + params = id; + id = browser.runtime.id; + } + if (onExtraContentsBelowChanged.invoked) + return; + onExtraContentsBelowChanged.invoked = true; + window.requestAnimationFrame(() => { + onExtraContentsBelowChanged.invoked = false; + if (params.container != mDummyTab.extraItemsContainerBelowRoot) { + setExtraContentsToContainer(mDummyTab.extraItemsContainerBelowRoot, id, { + ...params, + container: mDummyTab.extraItemsContainerBelowRoot, + }); + } + throttledUpdateSize(); + }); +} + +function throttledUpdateSize() { + if (throttledUpdateSize.invoked) + return; + throttledUpdateSize.invoked = true; + window.requestAnimationFrame(() => { + throttledUpdateSize.invoked = false; + Size.updateTabs(); + Size.updateContainers(); + }); +} + +export function clearExtraTabContentsIn(tab, id) { + if (!tab || !tab.$TST.element) + return; + if (!id) // the addon id is optional + id = browser.runtime.id; + clearExtraTabContentsInElement(tab.$TST.element, id); +} + +function clearExtraTabContentsInElement(tabElement, id) { + if (!id) // the addon id is optional + id = browser.runtime.id; + setExtraTabContentsToElement(tabElement, id, { place: 'tab-indent' }); + setExtraTabContentsToElement(tabElement, id, { place: 'tab-front' }); + setExtraTabContentsToElement(tabElement, id, { place: 'tab-behind' }); + setExtraTabContentsToElement(tabElement, id, { place: 'tab-above' }); + setExtraTabContentsToElement(tabElement, id, { place: 'tab-below' }); + onExtraContentsAboveChanged(id); + onExtraContentsBelowChanged(id); +} + +export function clearAllExtraTabContents(id) { + if (!id) // the addon id is optional + id = browser.runtime.id; + + if (!mAddonsWithExtraContents.has(id)) + return; + + for (const tabElement of document.querySelectorAll(kTREE_ITEM_ELEMENT_NAME)) { + clearExtraTabContentsInElement(tabElement, id); + } + setExtraNewTabButtonContents(id); + clearExtraTabbarTopContents(id); + clearExtraTabbarBottomContents(id); + onExtraContentsAboveChanged(id); + onExtraContentsBelowChanged(id); + mAddonsWithExtraContents.delete(id); +} + + +export function setExtraNewTabButtonContents(id, params = {}) { + if (typeof id != 'string') { // addon id is optional + params = id; + id = browser.runtime.id; + } + for (const container of mNewTabButtonExtraItemsContainerRoots) { + setExtraContentsToContainer(container, id, params); + } + Sidebar.reserveToUpdateTabbarLayout({ + reason: Constants.kTABBAR_UPDATE_REASON_RESIZE, + timeout: 100, + }); +} + +export function clearExtraNewTabButtonContents(id) { + if (!id) + setExtraNewTabButtonContents({}); + setExtraNewTabButtonContents(id, {}); +} + + +export function setExtraTabbarTopContents(id, params = {}) { + if (typeof id != 'string') { // the addon id is optional + params = id; + id = browser.runtime.id; + } + setExtraContentsToContainer(mTabbarTopExtraItemsContainerRoot, id, params); + Sidebar.reserveToUpdateTabbarLayout({ + reason: Constants.kTABBAR_UPDATE_REASON_RESIZE, + timeout: 100, + }); +} + +export function clearExtraTabbarTopContents(id) { + if (!id) // the addon id is optional + id = browser.runtime.id; + setExtraTabbarTopContents(id, {}); +} + + +export function setExtraTabbarBottomContents(id, params = {}) { + if (typeof id != 'string') { + params = id; + id = browser.runtime.id; + } + setExtraContentsToContainer(mTabbarBottomExtraItemsContainerRoot, id, params); + Sidebar.reserveToUpdateTabbarLayout({ + reason: Constants.kTABBAR_UPDATE_REASON_RESIZE, + timeout: 100, + }); +} + +export function clearExtraTabbarBottomContents(id) { + if (!id) // the addon id is optional + id = browser.runtime.id; + setExtraTabbarBottomContents(id, {}); +} + +function collectExtraContentsRoots({ tabs, place }) { + switch (String(place).toLowerCase()) { + case 'indent': // for backward compatibility + case 'tab-indent': + return (tabs || Tab.getAllTabs(mTargetWindow)).map(tab => tab.$TST.element.extraItemsContainerIndentRoot); + + case 'behind': // for backward compatibility + case 'tab-behind': + return (tabs || Tab.getAllTabs(mTargetWindow)).map(tab => tab.$TST.element.extraItemsContainerBehindRoot); + + case 'front': // for backward compatibility + case 'tab-front': + return (tabs || Tab.getAllTabs(mTargetWindow)).map(tab => tab.$TST.element.extraItemsContainerFrontRoot); + + case 'tab-above': + return [ + ...(tabs || Tab.getAllTabs(mTargetWindow)).map(tab => tab.$TST.element.extraItemsContainerAboveRoot), + mDummyTab.extraItemsContainerAboveRoot, + ]; + + case 'tab-below': + return [ + ...(tabs || Tab.getAllTabs(mTargetWindow)).map(tab => tab.$TST.element.extraItemsContainerBelowRoot), + mDummyTab.extraItemsContainerBelowRoot, + ]; + + case 'newtabbutton': + case 'new-tab-button': + case 'newtab-button': + return mNewTabButtonExtraItemsContainerRoots; + + case 'tabbar-top': + return [mTabbarTopExtraItemsContainerRoot]; + + case 'tabbar-bottom': + return [mTabbarBottomExtraItemsContainerRoot]; + + default: + return []; + } +} + +function setExtraContentsProperties({ id, tabs, place, part, properties }) { + if (!id || !part || !properties) + return; + + const roots = collectExtraContentsRoots({ id, tabs, place }); + for (const root of roots) { + const node = root.querySelector(`[part~="${getExtraContentsPartName(id)}"][part~="${part}"]`); + if (!node) + continue; + for (const [property, value] of Object.entries(properties)) { + node[property] = value; + } + } +} + +function focusToExtraContents({ id, tabs, place, part }) { + if (!id || !part) + return; + + const roots = collectExtraContentsRoots({ id, tabs, place }); + for (const root of roots) { + const node = root.querySelector(`[part~="${getExtraContentsPartName(id)}"][part~="${part}"]`); + if (!node || typeof node.focus != 'function') + continue; + node.focus(); + } +} + + +const mAddonStyles = new Map(); + +function installStyle(id, style) { + let styleElement = mAddonStyles.get(id); + if (!styleElement) { + styleElement = document.createElement('style'); + styleElement.setAttribute('type', 'text/css'); + document.head.insertBefore(styleElement, document.querySelector('#addons-style-rules')); + mAddonStyles.set(id, styleElement); + } + styleElement.textContent = (style || '').replace(/%EXTRA_CONTENTS_PART%/gi, getExtraContentsPartName(id)); +} + +function uninstallStyle(id) { + const styleElement = mAddonStyles.get(id); + if (!styleElement) + return; + document.head.removeChild(styleElement); + mAddonStyles.delete(id); +} + + +// we should not handle dblclick on #tabbar here - it is handled by mouse-event-listener.js +for (const container of document.querySelectorAll('#tabbar-top, #tabbar-bottom')) { + container.addEventListener('dblclick', onExtraContentsDblClick, { capture: true }); +} +document.addEventListener('keydown', onExtraContentsKeyEvent, { capture: true }); +document.addEventListener('keyup', onExtraContentsKeyEvent, { capture: true }); +document.addEventListener('input', onExtraContentsInput, { capture: true }); +document.addEventListener('change', onExtraContentsChange, { capture: true }); +document.addEventListener('compositionstart', onExtraContentsCompositionEvent, { capture: true }); +document.addEventListener('compositionupdate', onExtraContentsCompositionEvent, { capture: true }); +document.addEventListener('compositionend', onExtraContentsCompositionEvent, { capture: true }); +document.addEventListener('focus', onExtraContentsFocusEvent, { capture: true }); +document.addEventListener('blur', onExtraContentsFocusEvent, { capture: true }); + +async function onExtraContentsDblClick(event) { + const detail = EventUtils.getMouseEventDetail(event, null); + const extraContentsInfo = getOriginalExtraContentsTarget(event); + const allowed = await tryMouseOperationAllowedWithExtraContents( + TSTAPI.kNOTIFY_EXTRA_CONTENTS_DBLCLICKED, + null, + { detail }, + extraContentsInfo + ); + if (allowed) + return; +} + +async function notifyExtraContentsEvent(event, eventType, details = {}) { + const extraContentsInfo = getOriginalExtraContentsTarget(event); + if (!extraContentsInfo || + !extraContentsInfo.owners || + extraContentsInfo.owners.size == 0) + return; + + const target = EventUtils.getElementOriginalTarget(event); + if (target && + target.closest('tab-item, button, *[role="button"], input[type="submit"]')) { + event.stopPropagation(); + event.stopImmediatePropagation(); + event.preventDefault(); + } + + const livingTab = EventUtils.getTreeItemFromEvent(event); + const eventInfo = { + ...EventUtils.getTreeItemEventDetail(event, livingTab), + ...extraContentsInfo.fieldValues, + originalTarget: extraContentsInfo.target, + originalTargetPart: extraContentsInfo.targetPart, + $extraContentsInfo: null, + ...details, + }; + const options = { + targets: extraContentsInfo.owners, + }; + if (livingTab) { + eventInfo.tab = livingTab; + options.tabProperties = ['tab']; + options.cache = {}; + } + + await TSTAPI.tryOperationAllowed( + eventType, + eventInfo, + options + ); + if (eventInfo.tab) + eventInfo.tab.clearCache(); +} + +async function onExtraContentsKeyEvent(event) { + await notifyExtraContentsEvent( + event, + event.type == 'keydown' ? + TSTAPI.kNOTIFY_EXTRA_CONTENTS_KEYDOWN : + TSTAPI.kNOTIFY_EXTRA_CONTENTS_KEYUP, + { + key: event.key, + isComposing: event.isComposing, + locale: event.locale, + location: event.location, + repeat: event.repeat, + } + ); +} + +async function onExtraContentsInput(event) { + await notifyExtraContentsEvent( + event, + TSTAPI.kNOTIFY_EXTRA_CONTENTS_INPUT, + { + data: event.data, + inputType: event.inputType, + isComposing: event.isComposing, + } + ); +} + +async function onExtraContentsChange(event) { + await notifyExtraContentsEvent( + event, + TSTAPI.kNOTIFY_EXTRA_CONTENTS_CHANGE + ); +} + +async function onExtraContentsCompositionEvent(event) { + await notifyExtraContentsEvent( + event, + event.type == 'compositionstart' ? + TSTAPI.kNOTIFY_EXTRA_CONTENTS_COMPOSITIONSTART : + event.type == 'compositionupdate' ? + TSTAPI.kNOTIFY_EXTRA_CONTENTS_COMPOSITIONUPDATE : + TSTAPI.kNOTIFY_EXTRA_CONTENTS_COMPOSITIONEND, + { + data: event.data, + locale: event.locale, + } + ); +} + +async function onExtraContentsFocusEvent(event) { + const target = EventUtils.getElementOriginalTarget(event); + if (!target) + return {}; + + const extraContents = target.closest(`.extra-item`); + + let relatedTarget = null; + const relatedTargetNode = event.relatedTarget && EventUtils.getElementTarget(event.relatedTarget); + const relatedExtraContents = relatedTargetNode?.closest(`.extra-item`) + if (relatedExtraContents && + extraContents.dataset.owner == relatedExtraContents.dataset.owner) + relatedTarget = relatedTargetNode.outerHTML; + + await notifyExtraContentsEvent( + event, + event.type == 'focus' ? + TSTAPI.kNOTIFY_EXTRA_CONTENTS_FOCUS : + TSTAPI.kNOTIFY_EXTRA_CONTENTS_BLUR, + { relatedTarget } + ); +} + +function getFieldValues(event) { + const target = EventUtils.getElementOriginalTarget(event); + if (!target) + return {}; + + const fieldNode = target.closest('input, select, textarea'); + if (!fieldNode) + return {}; + + return { + fieldValue: 'value' in fieldNode ? fieldNode.value : null, + fieldChecked: 'checked' in fieldNode ? fieldNode.checked : null, + }; +} + +export function getOriginalExtraContentsTarget(event) { + try { + const target = EventUtils.getElementOriginalTarget(event); + const extraContents = target?.closest(`.extra-item`); + if (extraContents) { + const targetPart = target.closest(`[part]`) + return { + owners: new Set([extraContents.dataset.owner]), + target: cleanupExtraContentsPartName(target.outerHTML), + targetPart: cleanupExtraContentsPartName(targetPart.getAttribute('part')).trim(), + fieldValues: getFieldValues(event), + }; + } + } + catch(_error) { + // this may happen by mousedown on scrollbar + } + + return { + owners: new Set(), + target: null, + targetPart: null, + fieldValues: {}, + }; +} + +function cleanupExtraContentsPartName(string) { + return string + .replace(/ extra-contents-by-[-a-z0-9_]+\b/gi, ''); +} + +export async function tryMouseOperationAllowedWithExtraContents(eventType, rawEventType, mousedown, extraContentsInfo) { + const cache = {}; + + if (extraContentsInfo && + extraContentsInfo.owners && + extraContentsInfo.owners.size > 0) { + const eventInfo = { + ...mousedown.detail, + originalTarget: extraContentsInfo.target, + originalTargetPart: extraContentsInfo.targetPart, + ...extraContentsInfo.fieldValues, + $extraContentsInfo: null, + }; + const options = { + targets: extraContentsInfo.owners, + }; + if (mousedown.tab) { + eventInfo.tab = mousedown.tab; + options.tabProperties = ['tab']; + options.cache = cache; + } + const allowed = (await TSTAPI.tryOperationAllowed( + eventType, + eventInfo, + options + )) && (!rawEventType || await TSTAPI.tryOperationAllowed( + rawEventType, // for backward compatibility + eventInfo, + options + )); + if (!allowed) + return false; + } + + if (rawEventType) { + const eventInfo = { + ...mousedown.detail, + $extraContentsInfo: null, + }; + const options = { + except: extraContentsInfo?.owners, + }; + if (mousedown.tab) { + eventInfo.tab = mousedown.tab; + options.tabProperties = ['tab']; + options.cache = cache; + } + const allowed = await TSTAPI.tryOperationAllowed( + rawEventType, + eventInfo, + options + ); + if (!allowed) + return false; + } + + return true; +} diff --git a/waterfox/browser/themes/jar.mn b/waterfox/browser/themes/jar.mn index 34999fb244cc..f77fc6e4cca8 100644 --- a/waterfox/browser/themes/jar.mn +++ b/waterfox/browser/themes/jar.mn @@ -8,3 +8,4 @@ browser.jar: skin/classic/browser/userContent.css (lepton/leptonContent.css) skin/classic/browser/lepton/ (lepton/icons/*.svg) * skin/classic/browser/waterfox/general.css (waterfox/general.css) + skin/classic/browser/waterfox/tree-vertical-tabs-24x24.svg (waterfox/tree-vertical-tabs-24x24.svg) diff --git a/waterfox/browser/themes/waterfox/general.css b/waterfox/browser/themes/waterfox/general.css index 0598f539dff7..6eaf8321124a 100644 --- a/waterfox/browser/themes/waterfox/general.css +++ b/waterfox/browser/themes/waterfox/general.css @@ -186,4 +186,25 @@ .tab-close-button { display: none !important } -} \ No newline at end of file +} + +/* Tree Vertical Tabs */ + +/* tabs sidebar box */ +#tree-vertical-tabs-box { + background-color: var(--sidebar-background-color); + color: var(--sidebar-text-color); + text-shadow: none; + min-width: 50px; +} +:root[BookmarksToolbarOverlapsBrowser] #tree-vertical-tabs-box { + padding-top: var(--bookmarks-toolbar-overlapping-browser-height); +} + +#tree-vertical-tabs { + flex: 1; +} + +#category-tree > .category-icon { + list-style-image: url("./tree-vertical-tabs-24x24.svg#default-context"); +} diff --git a/waterfox/browser/themes/waterfox/tree-vertical-tabs-24x24.svg b/waterfox/browser/themes/waterfox/tree-vertical-tabs-24x24.svg new file mode 100644 index 000000000000..c171fcf3cb4c --- /dev/null +++ b/waterfox/browser/themes/waterfox/tree-vertical-tabs-24x24.svg @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +