toolbox-tabs-order-manager.js (10319B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 const { AddonManager } = ChromeUtils.importESModule( 8 "resource://gre/modules/AddonManager.sys.mjs", 9 // AddonManager is a singleton, never create two instances of it. 10 { global: "shared" } 11 ); 12 const { 13 gDevTools, 14 } = require("resource://devtools/client/framework/devtools.js"); 15 const PREFERENCE_NAME = "devtools.toolbox.tabsOrder"; 16 17 /** 18 * Manage the order of devtools tabs. 19 */ 20 class ToolboxTabsOrderManager { 21 constructor(toolbox, onOrderUpdated, panelDefinitions) { 22 this.toolbox = toolbox; 23 this.onOrderUpdated = onOrderUpdated; 24 this.currentPanelDefinitions = panelDefinitions || []; 25 26 this.onMouseDown = this.onMouseDown.bind(this); 27 this.onMouseMove = this.onMouseMove.bind(this); 28 this.onMouseUp = this.onMouseUp.bind(this); 29 30 Services.prefs.addObserver(PREFERENCE_NAME, this.onOrderUpdated); 31 } 32 33 async destroy() { 34 Services.prefs.removeObserver(PREFERENCE_NAME, this.onOrderUpdated); 35 36 // Call mouseUp() to clear the state to prepare for in case a dragging was in progress 37 // when the destroy() was called. 38 await this.onMouseUp(); 39 } 40 41 insertBefore(target) { 42 const xBefore = this.dragTarget.offsetLeft; 43 this.toolboxTabsElement.insertBefore(this.dragTarget, target); 44 const xAfter = this.dragTarget.offsetLeft; 45 this.dragStartX += xAfter - xBefore; 46 this.isOrderUpdated = true; 47 } 48 49 isFirstTab(tabElement) { 50 return !tabElement.previousSibling; 51 } 52 53 isLastTab(tabElement) { 54 return ( 55 !tabElement.nextSibling || 56 tabElement.nextSibling.id === "tools-chevron-menu-button" 57 ); 58 } 59 60 isRTL() { 61 return this.toolbox.direction === "rtl"; 62 } 63 64 async saveOrderPreference() { 65 const tabs = [...this.toolboxTabsElement.querySelectorAll(".devtools-tab")]; 66 const tabIds = tabs.map(tab => tab.dataset.extensionId || tab.dataset.id); 67 // Concat the overflowed tabs id since they are not contained in visible tabs. 68 // The overflowed tabs cannot be reordered so we just append the id from current 69 // panel definitions on their order. 70 const overflowedTabIds = this.currentPanelDefinitions 71 .filter(definition => !tabs.some(tab => tab.dataset.id === definition.id)) 72 .map(definition => definition.extensionId || definition.id); 73 const currentTabIds = tabIds.concat(overflowedTabIds); 74 const dragTargetId = 75 this.dragTarget.dataset.extensionId || this.dragTarget.dataset.id; 76 const prefIds = getTabsOrderFromPreference(); 77 const absoluteIds = toAbsoluteOrder(prefIds, currentTabIds, dragTargetId); 78 79 // Remove panel id which is not in panel definitions and addons list. 80 const extensions = await AddonManager.getAllAddons(); 81 const definitions = gDevTools.getToolDefinitionArray(); 82 const result = absoluteIds.filter( 83 id => 84 definitions.find(d => id === (d.extensionId || d.id)) || 85 extensions.find(e => id === e.id) 86 ); 87 88 Services.prefs.setCharPref(PREFERENCE_NAME, result.join(",")); 89 } 90 91 setCurrentPanelDefinitions(currentPanelDefinitions) { 92 this.currentPanelDefinitions = currentPanelDefinitions; 93 } 94 95 onMouseDown(e) { 96 if (!e.target.classList.contains("devtools-tab")) { 97 return; 98 } 99 100 this.dragStartX = e.pageX; 101 this.dragTarget = e.target; 102 this.previousPageX = e.pageX; 103 this.toolboxContainerElement = 104 this.dragTarget.closest("#toolbox-container"); 105 this.toolboxTabsElement = this.dragTarget.closest(".toolbox-tabs"); 106 this.isOrderUpdated = false; 107 this.eventTarget = this.dragTarget.ownerGlobal.top; 108 109 this.eventTarget.addEventListener("mousemove", this.onMouseMove); 110 this.eventTarget.addEventListener("mouseup", this.onMouseUp); 111 112 this.toolboxContainerElement.classList.add("tabs-reordering"); 113 } 114 115 onMouseMove(e) { 116 const diffPageX = e.pageX - this.previousPageX; 117 let dragTargetCenterX = 118 this.dragTarget.offsetLeft + diffPageX + this.dragTarget.clientWidth / 2; 119 let isDragTargetPreviousSibling = false; 120 121 const tabElements = 122 this.toolboxTabsElement.querySelectorAll(".devtools-tab"); 123 124 // Calculate the minimum and maximum X-offset that can be valid for the drag target. 125 const firstElement = tabElements[0]; 126 const firstElementCenterX = 127 firstElement.offsetLeft + firstElement.clientWidth / 2; 128 const lastElement = tabElements[tabElements.length - 1]; 129 const lastElementCenterX = 130 lastElement.offsetLeft + lastElement.clientWidth / 2; 131 const max = Math.max(firstElementCenterX, lastElementCenterX); 132 const min = Math.min(firstElementCenterX, lastElementCenterX); 133 134 // Normalize the target center X so to remain between the first and last tab. 135 dragTargetCenterX = Math.min(max, dragTargetCenterX); 136 dragTargetCenterX = Math.max(min, dragTargetCenterX); 137 138 for (const tabElement of tabElements) { 139 if (tabElement === this.dragTarget) { 140 isDragTargetPreviousSibling = true; 141 continue; 142 } 143 144 // Is the dragTarget near the center of the other tab? 145 const anotherCenterX = tabElement.offsetLeft + tabElement.clientWidth / 2; 146 const distanceWithDragTarget = Math.abs( 147 dragTargetCenterX - anotherCenterX 148 ); 149 const isReplaceable = distanceWithDragTarget < tabElement.clientWidth / 3; 150 151 if (isReplaceable) { 152 const replaceableElement = isDragTargetPreviousSibling 153 ? tabElement.nextSibling 154 : tabElement; 155 this.insertBefore(replaceableElement); 156 break; 157 } 158 } 159 160 let distance = e.pageX - this.dragStartX; 161 162 // To accomodate for RTL locales, we cannot rely on the first/last element of the 163 // NodeList. We cannot have negative distances for the leftmost tab, and we cannot 164 // have positive distances for the rightmost tab. 165 const isFirstTab = this.isFirstTab(this.dragTarget); 166 const isLastTab = this.isLastTab(this.dragTarget); 167 const isLeftmostTab = this.isRTL() ? isLastTab : isFirstTab; 168 const isRightmostTab = this.isRTL() ? isFirstTab : isLastTab; 169 170 if ((isLeftmostTab && distance < 0) || (isRightmostTab && distance > 0)) { 171 // If the drag target is already edge of the tabs and the mouse will make the 172 // element to move to same direction more, keep the position. 173 distance = 0; 174 } 175 176 this.dragTarget.style.left = `${distance}px`; 177 this.previousPageX = e.pageX; 178 } 179 180 async onMouseUp() { 181 if (!this.dragTarget) { 182 // The case in here has two type: 183 // 1. Although destroy method was called, it was not during reordering. 184 // 2. Although mouse event occur, destroy method was called during reordering. 185 return; 186 } 187 188 if (this.isOrderUpdated) { 189 await this.saveOrderPreference(); 190 191 // Log which tabs reordered. The question we want to answer is: 192 // "How frequently are the tabs re-ordered, also which tabs get re-ordered?" 193 const toolId = 194 this.dragTarget.dataset.extensionId || this.dragTarget.dataset.id; 195 Glean.devtoolsToolbox.tabsReordered[toolId].add(1); 196 } 197 198 this.eventTarget.removeEventListener("mousemove", this.onMouseMove); 199 this.eventTarget.removeEventListener("mouseup", this.onMouseUp); 200 201 this.toolboxContainerElement.classList.remove("tabs-reordering"); 202 this.dragTarget.style.left = null; 203 this.dragTarget = null; 204 this.toolboxContainerElement = null; 205 this.toolboxTabsElement = null; 206 this.eventTarget = null; 207 } 208 } 209 210 function getTabsOrderFromPreference() { 211 const pref = Services.prefs.getCharPref(PREFERENCE_NAME, ""); 212 return pref ? pref.split(",") : []; 213 } 214 215 function sortPanelDefinitions(definitions) { 216 const toolIds = getTabsOrderFromPreference(); 217 218 return definitions.sort((a, b) => { 219 let orderA = toolIds.indexOf(a.extensionId || a.id); 220 let orderB = toolIds.indexOf(b.extensionId || b.id); 221 orderA = orderA < 0 ? Number.MAX_VALUE : orderA; 222 orderB = orderB < 0 ? Number.MAX_VALUE : orderB; 223 return orderA - orderB; 224 }); 225 } 226 227 /** 228 * This function returns absolute tab ids that were merged the both ids that are in 229 * preference and tabs. 230 * Some tabs added with add-ons etc show/hide depending on conditions. 231 * However, all of tabs that include hidden tab always keep the relationship with 232 * left side tab, except in case the left tab was target of dragging. If the left 233 * tab has been moved, it keeps its relationship with the tab next to it. 234 * 235 * Case 1: Drag a tab to left 236 * currentTabIds: [T1, T2, T3, T4, T5] 237 * prefIds : [T1, T2, T3, E1(hidden), T4, T5] 238 * drag T4 : [T1, T2, T4, T3, T5] 239 * result : [T1, T2, T4, T3, E1, T5] 240 * 241 * Case 2: Drag a tab to right 242 * currentTabIds: [T1, T2, T3, T4, T5] 243 * prefIds : [T1, T2, T3, E1(hidden), T4, T5] 244 * drag T2 : [T1, T3, T4, T2, T5] 245 * result : [T1, T3, E1, T4, T2, T5] 246 * 247 * Case 3: Hidden tab was left end and drag a tab to left end 248 * currentTabIds: [T1, T2, T3, T4, T5] 249 * prefIds : [E1(hidden), T1, T2, T3, T4, T5] 250 * drag T4 : [T4, T1, T2, T3, T5] 251 * result : [E1, T4, T1, T2, T3, T5] 252 * 253 * Case 4: Hidden tab was right end and drag a tab to right end 254 * currentTabIds: [T1, T2, T3, T4, T5] 255 * prefIds : [T1, T2, T3, T4, T5, E1(hidden)] 256 * drag T1 : [T2, T3, T4, T5, T1] 257 * result : [T2, T3, T4, T5, E1, T1] 258 * 259 * @param Array - prefIds: id array of preference 260 * @param Array - currentTabIds: id array of appearanced tabs 261 * @param String - dragTargetId: id of dragged target 262 * @return Array 263 */ 264 function toAbsoluteOrder(prefIds, currentTabIds, dragTargetId) { 265 currentTabIds = [...currentTabIds]; 266 let indexAtCurrentTabs = 0; 267 268 for (const prefId of prefIds) { 269 if (prefId === dragTargetId) { 270 // do nothing 271 } else if (currentTabIds.includes(prefId)) { 272 indexAtCurrentTabs = currentTabIds.indexOf(prefId) + 1; 273 } else { 274 currentTabIds.splice(indexAtCurrentTabs, 0, prefId); 275 indexAtCurrentTabs += 1; 276 } 277 } 278 279 return currentTabIds; 280 } 281 282 module.exports.ToolboxTabsOrderManager = ToolboxTabsOrderManager; 283 module.exports.sortPanelDefinitions = sortPanelDefinitions; 284 module.exports.toAbsoluteOrder = toAbsoluteOrder;