ToolboxTabs.js (9723B)
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 file, 3 * You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 "use strict"; 5 6 const { 7 Component, 8 createFactory, 9 createRef, 10 } = require("resource://devtools/client/shared/vendor/react.mjs"); 11 const dom = require("resource://devtools/client/shared/vendor/react-dom-factories.js"); 12 const PropTypes = require("resource://devtools/client/shared/vendor/react-prop-types.mjs"); 13 const { 14 ToolboxTabsOrderManager, 15 } = require("resource://devtools/client/framework/toolbox-tabs-order-manager.js"); 16 17 const { div } = dom; 18 19 const ToolboxTab = createFactory( 20 require("resource://devtools/client/framework/components/ToolboxTab.js") 21 ); 22 23 loader.lazyGetter(this, "MenuButton", function () { 24 return createFactory( 25 require("resource://devtools/client/shared/components/menu/MenuButton.js") 26 ); 27 }); 28 loader.lazyGetter(this, "MenuItem", function () { 29 return createFactory( 30 require("resource://devtools/client/shared/components/menu/MenuItem.js") 31 ); 32 }); 33 loader.lazyGetter(this, "MenuList", function () { 34 return createFactory( 35 require("resource://devtools/client/shared/components/menu/MenuList.js") 36 ); 37 }); 38 39 // 26px is chevron devtools button width.(i.e. tools-chevronmenu) 40 const CHEVRON_BUTTON_WIDTH = 26; 41 42 class ToolboxTabs extends Component { 43 // See toolbox-toolbar propTypes for details on the props used here. 44 static get propTypes() { 45 return { 46 currentToolId: PropTypes.string, 47 focusButton: PropTypes.func, 48 focusedButton: PropTypes.string, 49 highlightedTools: PropTypes.object, 50 panelDefinitions: PropTypes.array, 51 selectTool: PropTypes.func, 52 toolbox: PropTypes.object, 53 visibleToolboxButtonCount: PropTypes.number.isRequired, 54 L10N: PropTypes.object, 55 onTabsOrderUpdated: PropTypes.func.isRequired, 56 }; 57 } 58 59 constructor(props) { 60 super(props); 61 62 this.state = { 63 // Array of overflowed tool id. 64 overflowedTabIds: [], 65 }; 66 67 this.wrapperEl = createRef(); 68 69 // Map with tool Id and its width size. This lifecycle is out of React's 70 // lifecycle. If a tool is registered, ToolboxTabs will add target tool id 71 // to this map. ToolboxTabs will never remove tool id from this cache. 72 this._cachedToolTabsWidthMap = new Map(); 73 74 this._resizeTimerId = null; 75 this.resizeHandler = this.resizeHandler.bind(this); 76 77 const { toolbox, onTabsOrderUpdated, panelDefinitions } = props; 78 this._tabsOrderManager = new ToolboxTabsOrderManager( 79 toolbox, 80 onTabsOrderUpdated, 81 panelDefinitions 82 ); 83 } 84 85 componentDidMount() { 86 window.addEventListener("resize", this.resizeHandler); 87 this.updateCachedToolTabsWidthMap(); 88 this.updateOverflowedTabs(); 89 } 90 91 // FIXME: https://bugzilla.mozilla.org/show_bug.cgi?id=1774507 92 UNSAFE_componentWillUpdate(nextProps, nextState) { 93 if (this.shouldUpdateToolboxTabs(this.props, nextProps)) { 94 // Force recalculate and render in this cycle if panel definition has 95 // changed or selected tool has changed. 96 nextState.overflowedTabIds = []; 97 } 98 } 99 100 componentDidUpdate(prevProps) { 101 if (this.shouldUpdateToolboxTabs(prevProps, this.props)) { 102 this.updateCachedToolTabsWidthMap(); 103 this.updateOverflowedTabs(); 104 this._tabsOrderManager.setCurrentPanelDefinitions( 105 this.props.panelDefinitions 106 ); 107 } 108 } 109 110 componentWillUnmount() { 111 window.removeEventListener("resize", this.resizeHandler); 112 window.cancelIdleCallback(this._resizeTimerId); 113 this._tabsOrderManager.destroy(); 114 } 115 116 /** 117 * Check if two array of ids are the same or not. 118 */ 119 equalToolIdArray(prevPanels, nextPanels) { 120 if (prevPanels.length !== nextPanels.length) { 121 return false; 122 } 123 124 // Check panel definitions even if both of array size is same. 125 // For example, the case of changing the tab's order. 126 return prevPanels.join("-") === nextPanels.join("-"); 127 } 128 129 /** 130 * Return true if we should update the overflowed tabs. 131 */ 132 shouldUpdateToolboxTabs(prevProps, nextProps) { 133 if ( 134 prevProps.currentToolId !== nextProps.currentToolId || 135 prevProps.visibleToolboxButtonCount !== 136 nextProps.visibleToolboxButtonCount 137 ) { 138 return true; 139 } 140 141 const prevPanels = prevProps.panelDefinitions.map(def => def.id); 142 const nextPanels = nextProps.panelDefinitions.map(def => def.id); 143 return !this.equalToolIdArray(prevPanels, nextPanels); 144 } 145 146 /** 147 * Update the Map of tool id and tool tab width. 148 */ 149 updateCachedToolTabsWidthMap() { 150 const utils = window.windowUtils; 151 // Force a reflow before calling getBoundingWithoutFlushing on each tab. 152 this.wrapperEl.current.clientWidth; 153 154 for (const tab of this.wrapperEl.current.querySelectorAll( 155 ".devtools-tab" 156 )) { 157 const tabId = tab.id.replace("toolbox-tab-", ""); 158 if (!this._cachedToolTabsWidthMap.has(tabId)) { 159 const rect = utils.getBoundsWithoutFlushing(tab); 160 this._cachedToolTabsWidthMap.set(tabId, rect.width); 161 } 162 } 163 } 164 165 /** 166 * Update the overflowed tab array from currently displayed tool tab. 167 * If calculated result is the same as the current overflowed tab array, this 168 * function will not update state. 169 */ 170 updateOverflowedTabs() { 171 const toolboxWidth = parseInt( 172 getComputedStyle(this.wrapperEl.current).width, 173 10 174 ); 175 const { currentToolId } = this.props; 176 const enabledTabs = this.props.panelDefinitions.map(def => def.id); 177 let sumWidth = 0; 178 const visibleTabs = []; 179 180 for (const id of enabledTabs) { 181 const width = this._cachedToolTabsWidthMap.get(id); 182 sumWidth += width; 183 if (sumWidth <= toolboxWidth) { 184 visibleTabs.push(id); 185 } else { 186 sumWidth = sumWidth - width + CHEVRON_BUTTON_WIDTH; 187 188 // If toolbox can't display the Chevron, remove the last tool tab. 189 if (sumWidth > toolboxWidth) { 190 const removeTabId = visibleTabs.pop(); 191 sumWidth -= this._cachedToolTabsWidthMap.get(removeTabId); 192 } 193 break; 194 } 195 } 196 197 // If the selected tab is in overflowed tabs, insert it into a visible 198 // toolbox. 199 if ( 200 !visibleTabs.includes(currentToolId) && 201 enabledTabs.includes(currentToolId) 202 ) { 203 const selectedToolWidth = this._cachedToolTabsWidthMap.get(currentToolId); 204 while ( 205 sumWidth + selectedToolWidth > toolboxWidth && 206 visibleTabs.length 207 ) { 208 const removingToolId = visibleTabs.pop(); 209 const removingToolWidth = 210 this._cachedToolTabsWidthMap.get(removingToolId); 211 sumWidth -= removingToolWidth; 212 } 213 214 // If toolbox width is narrow, toolbox display only chevron menu. 215 // i.e. All tool tabs will overflow. 216 if (sumWidth + selectedToolWidth <= toolboxWidth) { 217 visibleTabs.push(currentToolId); 218 } 219 } 220 221 const willOverflowTabs = enabledTabs.filter( 222 id => !visibleTabs.includes(id) 223 ); 224 if (!this.equalToolIdArray(this.state.overflowedTabIds, willOverflowTabs)) { 225 this.setState({ overflowedTabIds: willOverflowTabs }); 226 } 227 } 228 229 resizeHandler() { 230 window.cancelIdleCallback(this._resizeTimerId); 231 this._resizeTimerId = window.requestIdleCallback( 232 () => { 233 this.updateOverflowedTabs(); 234 }, 235 { timeout: 100 } 236 ); 237 } 238 239 renderToolsChevronMenuList() { 240 const { panelDefinitions, selectTool } = this.props; 241 242 const items = []; 243 for (const { id, label, icon } of panelDefinitions) { 244 if (this.state.overflowedTabIds.includes(id)) { 245 items.push( 246 MenuItem({ 247 key: id, 248 id: "tools-chevron-menupopup-" + id, 249 label, 250 type: "checkbox", 251 onClick: () => { 252 selectTool(id, "tab_switch"); 253 }, 254 icon, 255 }) 256 ); 257 } 258 } 259 260 return MenuList({ id: "tools-chevron-menupopup" }, items); 261 } 262 263 /** 264 * Render a button to access overflowed tools, displayed only when the toolbar 265 * presents an overflow. 266 */ 267 renderToolsChevronButton() { 268 const { toolbox } = this.props; 269 270 return MenuButton( 271 { 272 id: "tools-chevron-menu-button", 273 menuId: "tools-chevron-menu-button-panel", 274 className: "devtools-tabbar-button tools-chevron-menu", 275 toolboxDoc: toolbox.doc, 276 }, 277 this.renderToolsChevronMenuList() 278 ); 279 } 280 281 /** 282 * Render all of the tabs, based on the panel definitions and builds out 283 * a toolbox tab for each of them. Will render the chevron button if the 284 * container has an overflow. 285 */ 286 render() { 287 const { 288 currentToolId, 289 focusButton, 290 focusedButton, 291 highlightedTools, 292 panelDefinitions, 293 selectTool, 294 } = this.props; 295 296 const tabs = panelDefinitions.map(panelDefinition => { 297 // Don't display overflowed tab. 298 if (!this.state.overflowedTabIds.includes(panelDefinition.id)) { 299 return ToolboxTab({ 300 key: panelDefinition.id, 301 currentToolId, 302 focusButton, 303 focusedButton, 304 highlightedTools, 305 panelDefinition, 306 selectTool, 307 }); 308 } 309 return null; 310 }); 311 312 return div( 313 { 314 className: "toolbox-tabs-wrapper", 315 ref: this.wrapperEl, 316 }, 317 div( 318 { 319 className: "toolbox-tabs", 320 onMouseDown: e => this._tabsOrderManager.onMouseDown(e), 321 }, 322 tabs, 323 this.state.overflowedTabIds.length 324 ? this.renderToolsChevronButton() 325 : null 326 ) 327 ); 328 } 329 } 330 331 module.exports = ToolboxTabs;