tab-stacking.js (64336B)
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 // Wrap in a block to prevent leaking to window scope. 8 { 9 const isTab = element => gBrowser.isTab(element); 10 const isTabGroupLabel = element => gBrowser.isTabGroupLabel(element); 11 const isSplitViewWrapper = element => gBrowser.isSplitViewWrapper(element); 12 /** 13 * The elements in the tab strip from `this.dragAndDropElements` that contain 14 * logical information are: 15 * 16 * - <tab> (.tabbrowser-tab) 17 * - <tab-group> label element (.tab-group-label) 18 * - <tab-split-view-wrapper> 19 * 20 * The elements in the tab strip that contain the space inside of the <tabs> 21 * element are: 22 * 23 * - <tab> (.tabbrowser-tab) 24 * - <tab-group> label element wrapper (.tab-group-label-container) 25 * - <tab-split-view-wrapper> 26 * 27 * When working with tab strip items, if you need logical information, you 28 * can get it directly, e.g. `element.elementIndex` or `element._tPos`. If 29 * you need spatial information like position or dimensions, then you should 30 * call this function. For example, `elementToMove(element).getBoundingClientRect()` 31 * or `elementToMove(element).style.top`. 32 * 33 * @param {MozTabbrowserTab|typeof MozTabbrowserTabGroup.labelElement} element 34 * @returns {MozTabbrowserTab|vbox} 35 */ 36 const elementToMove = element => { 37 if (isTab(element) || isSplitViewWrapper(element)) { 38 return element; 39 } 40 if (isTabGroupLabel(element)) { 41 return element.closest(".tab-group-label-container"); 42 } 43 throw new Error(`Element "${element.tagName}" is not expected to move`); 44 }; 45 46 window.TabStacking = class extends window.TabDragAndDrop { 47 constructor(tabbrowserTabs) { 48 super(tabbrowserTabs); 49 } 50 51 // eslint-disable-next-line complexity 52 handle_drop(event) { 53 var dt = event.dataTransfer; 54 var dropEffect = dt.dropEffect; 55 var draggedTab; 56 let movingTabs; 57 /** @type {TabMetricsContext} */ 58 const dropMetricsContext = gBrowser.TabMetrics.userTriggeredContext( 59 gBrowser.TabMetrics.METRIC_SOURCE.DRAG_AND_DROP 60 ); 61 if (dt.mozTypesAt(0)[0] == TAB_DROP_TYPE) { 62 // tab copy or move 63 draggedTab = dt.mozGetDataAt(TAB_DROP_TYPE, 0); 64 // not our drop then 65 if (!draggedTab) { 66 return; 67 } 68 movingTabs = draggedTab._dragData.movingTabs; 69 draggedTab.container.tabDragAndDrop.finishMoveTogetherSelectedTabs( 70 draggedTab 71 ); 72 } 73 74 if (this._rtlMode) { 75 // In `startTabDrag` we reverse the moving tabs order to handle 76 // positioning and animation. For drop, we require the original 77 // order, so reverse back. 78 movingTabs?.reverse(); 79 } 80 81 let overPinnedDropIndicator = 82 this._pinnedDropIndicator.hasAttribute("visible") && 83 this._pinnedDropIndicator.hasAttribute("interactive"); 84 this._resetTabsAfterDrop(draggedTab?.ownerDocument); 85 86 this._tabDropIndicator.hidden = true; 87 event.stopPropagation(); 88 if (draggedTab && dropEffect == "copy") { 89 let duplicatedDraggedTab; 90 let duplicatedTabs = []; 91 let dropTarget = 92 this._tabbrowserTabs.dragAndDropElements[this._getDropIndex(event)]; 93 for (let tab of movingTabs) { 94 let duplicatedTab = gBrowser.duplicateTab(tab); 95 duplicatedTabs.push(duplicatedTab); 96 if (tab == draggedTab) { 97 duplicatedDraggedTab = duplicatedTab; 98 } 99 } 100 gBrowser.moveTabsBefore(duplicatedTabs, dropTarget, dropMetricsContext); 101 if (draggedTab.container != this._tabbrowserTabs || event.shiftKey) { 102 this._tabbrowserTabs.selectedItem = duplicatedDraggedTab; 103 } 104 } else if (draggedTab && draggedTab.container == this._tabbrowserTabs) { 105 let oldTranslateX = Math.round(draggedTab._dragData.translateX); 106 let oldTranslateY = Math.round(draggedTab._dragData.translateY); 107 let tabWidth = Math.round(draggedTab._dragData.tabWidth); 108 let tabHeight = Math.round(draggedTab._dragData.tabHeight); 109 let translateOffsetX = oldTranslateX % tabWidth; 110 let translateOffsetY = oldTranslateY % tabHeight; 111 let newTranslateX = oldTranslateX - translateOffsetX; 112 let newTranslateY = oldTranslateY - translateOffsetY; 113 let isPinned = draggedTab.pinned; 114 let numPinned = gBrowser.pinnedTabCount; 115 let tabs = this._tabbrowserTabs.dragAndDropElements.slice( 116 isPinned ? 0 : numPinned, 117 isPinned ? numPinned : undefined 118 ); 119 120 if (this._tabbrowserTabs.isContainerVerticalPinnedGrid(draggedTab)) { 121 // Update both translate axis for pinned vertical expanded tabs 122 if (oldTranslateX > 0 && translateOffsetX > tabWidth / 2) { 123 newTranslateX += tabWidth; 124 } else if (oldTranslateX < 0 && -translateOffsetX > tabWidth / 2) { 125 newTranslateX -= tabWidth; 126 } 127 if (oldTranslateY > 0 && translateOffsetY > tabHeight / 2) { 128 newTranslateY += tabHeight; 129 } else if (oldTranslateY < 0 && -translateOffsetY > tabHeight / 2) { 130 newTranslateY -= tabHeight; 131 } 132 } else { 133 let size = this._tabbrowserTabs.verticalMode ? "height" : "width"; 134 let screenAxis = this._tabbrowserTabs.verticalMode 135 ? "screenY" 136 : "screenX"; 137 let tabSize = this._tabbrowserTabs.verticalMode 138 ? tabHeight 139 : tabWidth; 140 let firstTab = tabs[0]; 141 let lastTab = tabs.at(-1); 142 let lastMovingTabScreen = movingTabs.at(-1)[screenAxis]; 143 let firstMovingTabScreen = movingTabs[0][screenAxis]; 144 let startBound = firstTab[screenAxis] - firstMovingTabScreen; 145 let endBound = 146 lastTab[screenAxis] + 147 window.windowUtils.getBoundsWithoutFlushing(lastTab)[size] - 148 (lastMovingTabScreen + tabSize); 149 if (this._tabbrowserTabs.verticalMode) { 150 newTranslateY = Math.min( 151 Math.max(oldTranslateY, startBound), 152 endBound 153 ); 154 } else { 155 newTranslateX = RTL_UI 156 ? Math.min(Math.max(oldTranslateX, endBound), startBound) 157 : Math.min(Math.max(oldTranslateX, startBound), endBound); 158 } 159 } 160 161 let { 162 dropElement, 163 dropBefore, 164 shouldCreateGroupOnDrop, 165 shouldDropIntoCollapsedTabGroup, 166 fromTabList, 167 } = draggedTab._dragData; 168 169 let dropIndex; 170 let directionForward = false; 171 if (fromTabList) { 172 dropIndex = this._getDropIndex(event); 173 if (dropIndex && dropIndex > movingTabs[0].elementIndex) { 174 dropIndex--; 175 directionForward = true; 176 } 177 } else if ( 178 draggedTab.currentIndex > tabs[tabs.length - 1].currentIndex 179 ) { 180 // There is a case where the currentIndex could be greater than the last item's in 181 // the container. If this is the case, dropIndex needs to be set to the last item's 182 // elementIndex to ensure the draggedTab/s are dropped in the last position. 183 dropIndex = tabs[tabs.length - 1].elementIndex; 184 } 185 186 const dragToPinTargets = [ 187 this._tabbrowserTabs.pinnedTabsContainer, 188 this._dragToPinPromoCard, 189 ]; 190 let shouldPin = 191 isTab(draggedTab) && 192 !draggedTab.pinned && 193 (overPinnedDropIndicator || 194 dragToPinTargets.some(el => el.contains(event.target))); 195 let shouldUnpin = 196 isTab(draggedTab) && 197 draggedTab.pinned && 198 this._tabbrowserTabs.arrowScrollbox.contains(event.target); 199 200 let shouldTranslate = 201 !gReduceMotion && 202 !shouldCreateGroupOnDrop && 203 !shouldDropIntoCollapsedTabGroup && 204 !isTabGroupLabel(draggedTab) && 205 !isSplitViewWrapper(draggedTab) && 206 !shouldPin && 207 !shouldUnpin; 208 if (this._tabbrowserTabs.isContainerVerticalPinnedGrid(draggedTab)) { 209 shouldTranslate &&= 210 (oldTranslateX && oldTranslateX != newTranslateX) || 211 (oldTranslateY && oldTranslateY != newTranslateY); 212 } else if (this._tabbrowserTabs.verticalMode) { 213 shouldTranslate &&= oldTranslateY && oldTranslateY != newTranslateY; 214 } else { 215 shouldTranslate &&= oldTranslateX && oldTranslateX != newTranslateX; 216 } 217 218 let moveTabs = () => { 219 if (dropIndex !== undefined) { 220 for (let tab of movingTabs) { 221 gBrowser.moveTabTo( 222 tab, 223 { elementIndex: dropIndex }, 224 dropMetricsContext 225 ); 226 if (!directionForward) { 227 dropIndex++; 228 } 229 } 230 } else if (dropElement && dropBefore) { 231 gBrowser.moveTabsBefore( 232 movingTabs, 233 dropElement, 234 dropMetricsContext 235 ); 236 } else if (dropElement && dropBefore != undefined) { 237 gBrowser.moveTabsAfter(movingTabs, dropElement, dropMetricsContext); 238 } 239 240 if (isTabGroupLabel(draggedTab)) { 241 this._setIsDraggingTabGroup(draggedTab.group, false); 242 this._expandGroupOnDrop(draggedTab); 243 } 244 }; 245 246 if (shouldPin || shouldUnpin) { 247 for (let item of movingTabs) { 248 if (shouldPin) { 249 gBrowser.pinTab(item, { 250 telemetrySource: 251 gBrowser.TabMetrics.METRIC_SOURCE.DRAG_AND_DROP, 252 }); 253 } else if (shouldUnpin) { 254 gBrowser.unpinTab(item); 255 } 256 } 257 } 258 259 if (shouldTranslate) { 260 let translationPromises = []; 261 for (let item of movingTabs) { 262 item = elementToMove(item); 263 let translationPromise = new Promise(resolve => { 264 item.toggleAttribute("tabdrop-samewindow", true); 265 item.style.transform = `translate(${newTranslateX}px, ${newTranslateY}px)`; 266 let postTransitionCleanup = () => { 267 item.removeAttribute("tabdrop-samewindow"); 268 resolve(); 269 }; 270 if (gReduceMotion) { 271 postTransitionCleanup(); 272 } else { 273 let onTransitionEnd = transitionendEvent => { 274 if ( 275 transitionendEvent.propertyName != "transform" || 276 transitionendEvent.originalTarget != item 277 ) { 278 return; 279 } 280 item.removeEventListener("transitionend", onTransitionEnd); 281 282 postTransitionCleanup(); 283 }; 284 item.addEventListener("transitionend", onTransitionEnd); 285 } 286 }); 287 translationPromises.push(translationPromise); 288 } 289 Promise.all(translationPromises).then(() => { 290 this.finishAnimateTabMove(); 291 moveTabs(); 292 }); 293 } else { 294 this.finishAnimateTabMove(); 295 if (shouldCreateGroupOnDrop) { 296 // This makes the tab group contents reflect the visual order of 297 // the tabs right before dropping. 298 let tabsInGroup = dropBefore 299 ? [...movingTabs, dropElement] 300 : [dropElement, ...movingTabs]; 301 gBrowser.addTabGroup(tabsInGroup, { 302 insertBefore: dropElement, 303 isUserTriggered: true, 304 color: draggedTab._dragData.tabGroupCreationColor, 305 telemetryUserCreateSource: "drag", 306 }); 307 } else if ( 308 shouldDropIntoCollapsedTabGroup && 309 isTabGroupLabel(dropElement) && 310 isTab(draggedTab) 311 ) { 312 // If the dragged tab is the active tab in a collapsed tab group 313 // and the user dropped it onto the label of its tab group, leave 314 // the dragged tab where it was. Otherwise, drop it into the target 315 // tab group. 316 if (dropElement.group != draggedTab.group) { 317 dropElement.group.addTabs(movingTabs, dropMetricsContext); 318 } 319 } else { 320 moveTabs(); 321 this._tabbrowserTabs._notifyBackgroundTab(movingTabs.at(-1)); 322 } 323 } 324 } else if (isTabGroupLabel(draggedTab)) { 325 gBrowser.adoptTabGroup(draggedTab.group, { 326 elementIndex: this._getDropIndex(event), 327 }); 328 } else if (isSplitViewWrapper(draggedTab)) { 329 gBrowser.adoptSplitView(draggedTab, { 330 elementIndex: this._getDropIndex(event), 331 }); 332 } else if (draggedTab) { 333 // Move the tabs into this window. To avoid multiple tab-switches in 334 // the original window, the selected tab should be adopted last. 335 const dropIndex = this._getDropIndex(event); 336 let newIndex = dropIndex; 337 let selectedTab; 338 let indexForSelectedTab; 339 for (let i = 0; i < movingTabs.length; ++i) { 340 const tab = movingTabs[i]; 341 if (tab.selected) { 342 selectedTab = tab; 343 indexForSelectedTab = newIndex; 344 } else { 345 const newTab = gBrowser.adoptTab(tab, { 346 elementIndex: newIndex, 347 selectTab: tab == draggedTab, 348 }); 349 if (newTab) { 350 ++newIndex; 351 } 352 } 353 } 354 if (selectedTab) { 355 const newTab = gBrowser.adoptTab(selectedTab, { 356 elementIndex: indexForSelectedTab, 357 selectTab: selectedTab == draggedTab, 358 }); 359 if (newTab) { 360 ++newIndex; 361 } 362 } 363 364 // Restore tab selection 365 gBrowser.addRangeToMultiSelectedTabs( 366 this._tabbrowserTabs.dragAndDropElements[dropIndex], 367 this._tabbrowserTabs.dragAndDropElements[newIndex - 1] 368 ); 369 } else { 370 // Pass true to disallow dropping javascript: or data: urls 371 let links; 372 try { 373 links = Services.droppedLinkHandler.dropLinks(event, true); 374 } catch (ex) {} 375 376 if (!links || links.length === 0) { 377 return; 378 } 379 380 let inBackground = Services.prefs.getBoolPref( 381 "browser.tabs.loadInBackground" 382 ); 383 if (event.shiftKey) { 384 inBackground = !inBackground; 385 } 386 387 let targetTab = this._getDragTarget(event, { ignoreSides: true }); 388 let userContextId = 389 this._tabbrowserTabs.selectedItem.getAttribute("usercontextid"); 390 let replace = isTab(targetTab); 391 let newIndex = this._getDropIndex(event); 392 let urls = links.map(link => link.url); 393 let policyContainer = 394 Services.droppedLinkHandler.getPolicyContainer(event); 395 let triggeringPrincipal = 396 Services.droppedLinkHandler.getTriggeringPrincipal(event); 397 398 (async () => { 399 if ( 400 urls.length >= 401 Services.prefs.getIntPref("browser.tabs.maxOpenBeforeWarn") 402 ) { 403 // Sync dialog cannot be used inside drop event handler. 404 let answer = await OpenInTabsUtils.promiseConfirmOpenInTabs( 405 urls.length, 406 window 407 ); 408 if (!answer) { 409 return; 410 } 411 } 412 413 let nextItem = this._tabbrowserTabs.dragAndDropElements[newIndex]; 414 let tabGroup = isTab(nextItem) && nextItem.group; 415 gBrowser.loadTabs(urls, { 416 inBackground, 417 replace, 418 allowThirdPartyFixup: true, 419 targetTab, 420 elementIndex: newIndex, 421 tabGroup, 422 userContextId, 423 triggeringPrincipal, 424 policyContainer, 425 }); 426 })(); 427 } 428 429 for (let tab of this._tabbrowserTabs.dragAndDropElements) { 430 delete tab.currentIndex; 431 } 432 433 if (draggedTab) { 434 delete draggedTab._dragData; 435 } 436 } 437 438 /** 439 * Move together all selected tabs around the tab in param. 440 */ 441 _moveTogetherSelectedTabs(tab) { 442 let selectedTabs = gBrowser.selectedTabs; 443 let tabIndex = selectedTabs.indexOf(tab); 444 if (selectedTabs.some(t => t.pinned != tab.pinned)) { 445 throw new Error( 446 "Cannot move together a mix of pinned and unpinned tabs." 447 ); 448 } 449 let isGrid = this._tabbrowserTabs.isContainerVerticalPinnedGrid(tab); 450 let animate = !gReduceMotion; 451 452 tab._moveTogetherSelectedTabsData = { 453 finished: !animate, 454 }; 455 456 tab.toggleAttribute("multiselected-move-together", true); 457 458 let addAnimationData = movingTab => { 459 movingTab._moveTogetherSelectedTabsData = { 460 translateX: 0, 461 translateY: 0, 462 animate: true, 463 }; 464 movingTab.toggleAttribute("multiselected-move-together", true); 465 466 let postTransitionCleanup = () => { 467 movingTab._moveTogetherSelectedTabsData.animate = false; 468 }; 469 if (gReduceMotion) { 470 postTransitionCleanup(); 471 } else { 472 let onTransitionEnd = transitionendEvent => { 473 if ( 474 transitionendEvent.propertyName != "transform" || 475 transitionendEvent.originalTarget != movingTab 476 ) { 477 return; 478 } 479 movingTab.removeEventListener("transitionend", onTransitionEnd); 480 postTransitionCleanup(); 481 }; 482 483 movingTab.addEventListener("transitionend", onTransitionEnd); 484 } 485 486 let tabRect = tab.getBoundingClientRect(); 487 let movingTabRect = movingTab.getBoundingClientRect(); 488 movingTab._moveTogetherSelectedTabsData.translateX = 489 tabRect.x - movingTabRect.x; 490 movingTab._moveTogetherSelectedTabsData.translateY = 491 tabRect.y - movingTabRect.y; 492 }; 493 494 let selectedIndices = selectedTabs.map(t => t.elementIndex); 495 let currentIndex = 0; 496 let draggedRect = tab.getBoundingClientRect(); 497 let translateX = 0; 498 let translateY = 0; 499 500 // The currentIndex represents the indexes for all visible tab strip items after the 501 // selected tabs have moved together. These values make the math in _animateTabMove and 502 // _animateExpandedPinnedTabMove possible and less prone to edge cases when dragging 503 // multiple tabs. 504 for (let unmovingTab of this._tabbrowserTabs.dragAndDropElements) { 505 if (unmovingTab.multiselected) { 506 unmovingTab.currentIndex = tab.elementIndex; 507 // Skip because this multiselected tab should 508 // be shifted towards the dragged Tab. 509 continue; 510 } 511 if (unmovingTab.elementIndex > selectedIndices[currentIndex]) { 512 while ( 513 selectedIndices[currentIndex + 1] && 514 unmovingTab.elementIndex > selectedIndices[currentIndex + 1] 515 ) { 516 let currentRect = selectedTabs 517 .find(t => t.elementIndex == selectedIndices[currentIndex]) 518 .getBoundingClientRect(); 519 // For everything but the grid, we need to work out the shift required based 520 // on the size of the tabs being dragged together. 521 translateY -= currentRect.height; 522 translateX -= currentRect.width; 523 currentIndex++; 524 } 525 526 // Find the new index of the tab once selected tabs have moved together to use 527 // for positioning and animation 528 let isAfterDraggedTab = 529 unmovingTab.elementIndex - currentIndex > tab.elementIndex; 530 let newIndex = isAfterDraggedTab 531 ? unmovingTab.elementIndex - currentIndex 532 : unmovingTab.elementIndex - currentIndex - 1; 533 let newTranslateX = isAfterDraggedTab 534 ? translateX 535 : translateX - draggedRect.width; 536 let newTranslateY = isAfterDraggedTab 537 ? translateY 538 : translateY - draggedRect.height; 539 unmovingTab.currentIndex = newIndex; 540 unmovingTab._moveTogetherSelectedTabsData = { 541 translateX: 0, 542 translateY: 0, 543 }; 544 if (isGrid) { 545 // For the grid, use the position of the tab with the old index to dictate the 546 // translation needed for the background tab with the new index to move there. 547 let unmovingTabRect = unmovingTab.getBoundingClientRect(); 548 let oldTabRect = 549 this._tabbrowserTabs.dragAndDropElements[ 550 newIndex 551 ].getBoundingClientRect(); 552 unmovingTab._moveTogetherSelectedTabsData.translateX = 553 oldTabRect.x - unmovingTabRect.x; 554 unmovingTab._moveTogetherSelectedTabsData.translateY = 555 oldTabRect.y - unmovingTabRect.y; 556 } else if (this._tabbrowserTabs.verticalMode) { 557 unmovingTab._moveTogetherSelectedTabsData.translateY = 558 newTranslateY; 559 } else { 560 unmovingTab._moveTogetherSelectedTabsData.translateX = 561 newTranslateX; 562 } 563 } else { 564 unmovingTab.currentIndex = unmovingTab.elementIndex; 565 } 566 } 567 568 // Animate left or top selected tabs 569 for (let i = 0; i < tabIndex; i++) { 570 let movingTab = selectedTabs[i]; 571 addAnimationData(movingTab); 572 } 573 // Animate right or bottom selected tabs 574 for (let i = selectedTabs.length - 1; i > tabIndex; i--) { 575 let movingTab = selectedTabs[i]; 576 addAnimationData(movingTab); 577 } 578 579 // Slide the relevant tabs to their new position. 580 // non-moving tabs adjust for RTL 581 for (let item of this._tabbrowserTabs.dragAndDropElements) { 582 if ( 583 !tab._dragData.movingTabsSet.has(item) && 584 (item._moveTogetherSelectedTabsData?.translateX || 585 item._moveTogetherSelectedTabsData?.translateY) && 586 ((item.pinned && tab.pinned) || (!item.pinned && !tab.pinned)) 587 ) { 588 let element = elementToMove(item); 589 if (isGrid) { 590 element.style.transform = `translate(${(this._rtlMode ? -1 : 1) * item._moveTogetherSelectedTabsData.translateX}px, ${item._moveTogetherSelectedTabsData.translateY}px)`; 591 } else if (this._tabbrowserTabs.verticalMode) { 592 element.style.transform = `translateY(${item._moveTogetherSelectedTabsData.translateY}px)`; 593 } else { 594 element.style.transform = `translateX(${(this._rtlMode ? -1 : 1) * item._moveTogetherSelectedTabsData.translateX}px)`; 595 } 596 } 597 } 598 // moving tabs don't adjust for RTL 599 for (let item of selectedTabs) { 600 if ( 601 item._moveTogetherSelectedTabsData?.translateX || 602 item._moveTogetherSelectedTabsData?.translateY 603 ) { 604 let element = elementToMove(item); 605 element.style.transform = `translate(${item._moveTogetherSelectedTabsData.translateX}px, ${item._moveTogetherSelectedTabsData.translateY}px)`; 606 } 607 } 608 } 609 610 finishMoveTogetherSelectedTabs(tab) { 611 if ( 612 !tab._moveTogetherSelectedTabsData || 613 (tab._moveTogetherSelectedTabsData.finished && !gReduceMotion) 614 ) { 615 return; 616 } 617 618 if (tab._moveTogetherSelectedTabsData) { 619 tab._moveTogetherSelectedTabsData.finished = true; 620 } 621 622 let selectedTabs = gBrowser.selectedTabs; 623 let tabIndex = selectedTabs.indexOf(tab); 624 625 // Moving left or top tabs 626 for (let i = 0; i < tabIndex; i++) { 627 gBrowser.moveTabBefore(selectedTabs[i], tab); 628 } 629 630 // Moving right or bottom tabs 631 for (let i = selectedTabs.length - 1; i > tabIndex; i--) { 632 gBrowser.moveTabAfter(selectedTabs[i], tab); 633 } 634 635 for (let item of this._tabbrowserTabs.dragAndDropElements) { 636 delete item._moveTogetherSelectedTabsData; 637 item = elementToMove(item); 638 item.style.transform = ""; 639 item.removeAttribute("multiselected-move-together"); 640 } 641 } 642 643 /* In order to to drag tabs between both the pinned arrowscrollbox (pinned tab container) 644 and unpinned arrowscrollbox (tabbrowser-arrowscrollbox), the dragged tabs need to be 645 positioned absolutely. This results in a shift in the layout, filling the empty space. 646 This function updates the position and widths of elements affected by this layout shift 647 when the tab is first selected to be dragged. 648 */ 649 _updateTabStylesOnDrag(tab, dropEffect) { 650 let tabStripItemElement = elementToMove(tab); 651 tabStripItemElement.style.pointerEvents = 652 dropEffect == "copy" ? "auto" : ""; 653 if (tabStripItemElement.hasAttribute("dragtarget")) { 654 return; 655 } 656 let isPinned = tab.pinned; 657 let dragAndDropElements = this._tabbrowserTabs.dragAndDropElements; 658 let isGrid = this._tabbrowserTabs.isContainerVerticalPinnedGrid(tab); 659 let periphery = document.getElementById( 660 "tabbrowser-arrowscrollbox-periphery" 661 ); 662 663 if (isPinned && this._tabbrowserTabs.verticalMode) { 664 this._tabbrowserTabs.pinnedTabsContainer.setAttribute("dragActive", ""); 665 } 666 667 // Ensure tab containers retain size while tabs are dragged out of the layout 668 let pinnedRect = window.windowUtils.getBoundsWithoutFlushing( 669 this._tabbrowserTabs.pinnedTabsContainer.scrollbox 670 ); 671 let pinnedContainerRect = window.windowUtils.getBoundsWithoutFlushing( 672 this._tabbrowserTabs.pinnedTabsContainer 673 ); 674 let unpinnedRect = window.windowUtils.getBoundsWithoutFlushing( 675 this._tabbrowserTabs.arrowScrollbox.scrollbox 676 ); 677 let tabContainerRect = window.windowUtils.getBoundsWithoutFlushing( 678 this._tabbrowserTabs 679 ); 680 681 if (this._tabbrowserTabs.pinnedTabsContainer.firstChild) { 682 this._tabbrowserTabs.pinnedTabsContainer.scrollbox.style.height = 683 pinnedRect.height + "px"; 684 // Use "minHeight" so as not to interfere with user preferences for height. 685 this._tabbrowserTabs.pinnedTabsContainer.style.minHeight = 686 pinnedContainerRect.height + "px"; 687 this._tabbrowserTabs.pinnedTabsContainer.scrollbox.style.width = 688 pinnedRect.width + "px"; 689 } 690 this._tabbrowserTabs.arrowScrollbox.scrollbox.style.height = 691 unpinnedRect.height + "px"; 692 this._tabbrowserTabs.arrowScrollbox.scrollbox.style.width = 693 unpinnedRect.width + "px"; 694 695 let { movingTabs, movingTabsSet, expandGroupOnDrop } = tab._dragData; 696 /** @type {(MozTabbrowserTab|typeof MozTabbrowserTabGroup.labelElement)[]} */ 697 let suppressTransitionsFor = []; 698 /** @type {Map<MozTabbrowserTab, DOMRect>} */ 699 700 const tabsOrigBounds = new Map(); 701 702 for (let t of dragAndDropElements) { 703 t = elementToMove(t); 704 let tabRect = window.windowUtils.getBoundsWithoutFlushing(t); 705 706 // record where all the tabs were before we position:absolute the moving tabs 707 tabsOrigBounds.set(t, tabRect); 708 709 // Prevent flex rules from resizing non dragged tabs while the dragged 710 // tabs are positioned absolutely 711 t.style.maxWidth = tabRect.width + "px"; 712 // Prevent non-moving tab strip items from performing any animations 713 // at the very beginning of the drag operation; this prevents them 714 // from appearing to move while the dragged tabs are positioned absolutely 715 let isTabInCollapsingGroup = expandGroupOnDrop && t.group == tab.group; 716 if (!movingTabsSet.has(t) && !isTabInCollapsingGroup) { 717 t.style.transition = "none"; 718 suppressTransitionsFor.push(t); 719 } 720 } 721 722 if (suppressTransitionsFor.length) { 723 window 724 .promiseDocumentFlushed(() => {}) 725 .then(() => { 726 window.requestAnimationFrame(() => { 727 for (let t of suppressTransitionsFor) { 728 t.style.transition = ""; 729 } 730 }); 731 }); 732 } 733 734 // Use .tab-group-label-container, tab-split-view-wrapper or .tabbrowser-tab for size/position 735 // calculations. 736 let rect = 737 window.windowUtils.getBoundsWithoutFlushing(tabStripItemElement); 738 // Vertical tabs live under the #sidebar-main element which gets animated and has a 739 // transform style property, making it the containing block for all its descendants. 740 // Position:absolute elements need to account for this when updating position using 741 // other measurements whose origin is the viewport or documentElement's 0,0 742 let movingTabsOffsetX = window.windowUtils.getBoundsWithoutFlushing( 743 tabStripItemElement.offsetParent 744 ).x; 745 746 for (let movingTab of movingTabs) { 747 movingTab = elementToMove(movingTab); 748 movingTab.style.width = rect.width + "px"; 749 // "dragtarget" contains the following rules which must only be set AFTER the above 750 // elements have been adjusted. {z-index: 3 !important, position: absolute !important} 751 movingTab.setAttribute("dragtarget", ""); 752 if (isTabGroupLabel(tab)) { 753 if (this._tabbrowserTabs.verticalMode) { 754 movingTab.style.top = rect.top - tabContainerRect.top + "px"; 755 } else { 756 movingTab.style.left = rect.left - movingTabsOffsetX + "px"; 757 movingTab.style.height = rect.height + "px"; 758 } 759 } else if (isGrid) { 760 movingTab.style.top = rect.top - pinnedRect.top + "px"; 761 movingTab.style.left = rect.left - movingTabsOffsetX + "px"; 762 } else if (this._tabbrowserTabs.verticalMode) { 763 movingTab.style.top = rect.top - tabContainerRect.top + "px"; 764 } else if (this._rtlMode) { 765 movingTab.style.left = rect.left - movingTabsOffsetX + "px"; 766 } else { 767 movingTab.style.left = rect.left - movingTabsOffsetX + "px"; 768 } 769 } 770 771 if (movingTabs.length == 2) { 772 tab.setAttribute("small-stack", ""); 773 } else if (movingTabs.length > 2) { 774 tab.setAttribute("big-stack", ""); 775 } 776 777 if ( 778 !isPinned && 779 this._tabbrowserTabs.arrowScrollbox.hasAttribute("overflowing") 780 ) { 781 if (this._tabbrowserTabs.verticalMode) { 782 periphery.style.marginBlockStart = rect.height + "px"; 783 } else { 784 periphery.style.marginInlineStart = rect.width + "px"; 785 } 786 } else if ( 787 isPinned && 788 this._tabbrowserTabs.pinnedTabsContainer.hasAttribute("overflowing") 789 ) { 790 let pinnedPeriphery = document.createXULElement("hbox"); 791 pinnedPeriphery.id = "pinned-tabs-container-periphery"; 792 pinnedPeriphery.style.width = "100%"; 793 pinnedPeriphery.style.marginBlockStart = rect.height + "px"; 794 this._tabbrowserTabs.pinnedTabsContainer.appendChild(pinnedPeriphery); 795 } 796 797 let setElPosition = el => { 798 let origBounds = tabsOrigBounds.get(el); 799 if (this._tabbrowserTabs.verticalMode && origBounds.top > rect.top) { 800 el.style.top = rect.height + "px"; 801 } else if (!this._tabbrowserTabs.verticalMode) { 802 if (!this._rtlMode && origBounds.left > rect.left) { 803 el.style.left = rect.width + "px"; 804 } else if (this._rtlMode && origBounds.left < rect.left) { 805 el.style.left = -rect.width + "px"; 806 } 807 } 808 }; 809 810 let setGridElPosition = el => { 811 let origBounds = tabsOrigBounds.get(el); 812 if (!origBounds) { 813 // No bounds saved for this pinned tab 814 return; 815 } 816 // We use getBoundingClientRect and force a reflow as we need to know their new positions 817 // after making the moving tabs position:absolute 818 let newBounds = el.getBoundingClientRect(); 819 let shiftX = origBounds.x - newBounds.x; 820 let shiftY = origBounds.y - newBounds.y; 821 822 el.style.left = shiftX + "px"; 823 el.style.top = shiftY + "px"; 824 }; 825 826 // Update tabs in the same container as the dragged tabs so as not 827 // to fill the space when the dragged tabs become absolute 828 for (let t of dragAndDropElements) { 829 let tabIsPinned = t.pinned; 830 t = elementToMove(t); 831 if (!t.hasAttribute("dragtarget")) { 832 if ( 833 (!isPinned && !tabIsPinned) || 834 (tabIsPinned && isPinned && !isGrid) 835 ) { 836 setElPosition(t); 837 } else if (isGrid && tabIsPinned && isPinned) { 838 setGridElPosition(t); 839 } 840 } 841 } 842 843 // Handle the new tab button filling the space when the dragged tab 844 // position becomes absolute 845 if (!this._tabbrowserTabs.overflowing && !isPinned) { 846 if (this._tabbrowserTabs.verticalMode) { 847 periphery.style.top = `${rect.height}px`; 848 } else if (this._rtlMode) { 849 periphery.style.left = `${-rect.width}px`; 850 } else { 851 periphery.style.left = `${rect.width}px`; 852 } 853 } 854 } 855 856 // eslint-disable-next-line complexity 857 _animateTabMove(event) { 858 let draggedTab = event.dataTransfer.mozGetDataAt(TAB_DROP_TYPE, 0); 859 let dragData = draggedTab._dragData; 860 let movingTabs = dragData.movingTabs; 861 let movingTabsSet = dragData.movingTabsSet; 862 863 dragData.animLastScreenPos ??= this._tabbrowserTabs.verticalMode 864 ? dragData.screenY 865 : dragData.screenX; 866 let screen = this._tabbrowserTabs.verticalMode 867 ? event.screenY 868 : event.screenX; 869 if (screen == dragData.animLastScreenPos) { 870 return; 871 } 872 let screenForward = screen > dragData.animLastScreenPos; 873 dragData.animLastScreenPos = screen; 874 875 this._clearDragOverGroupingTimer(); 876 877 let isPinned = draggedTab.pinned; 878 let numPinned = gBrowser.pinnedTabCount; 879 let dragAndDropElements = this._tabbrowserTabs.dragAndDropElements; 880 let tabs = dragAndDropElements.slice( 881 isPinned ? 0 : numPinned, 882 isPinned ? numPinned : undefined 883 ); 884 885 if (this._rtlMode) { 886 tabs.reverse(); 887 } 888 889 let bounds = ele => window.windowUtils.getBoundsWithoutFlushing(ele); 890 let logicalForward = screenForward != this._rtlMode; 891 let screenAxis = this._tabbrowserTabs.verticalMode 892 ? "screenY" 893 : "screenX"; 894 let size = this._tabbrowserTabs.verticalMode ? "height" : "width"; 895 let translateAxis = this._tabbrowserTabs.verticalMode 896 ? "translateY" 897 : "translateX"; 898 let translateX = event.screenX - dragData.screenX; 899 let translateY = event.screenY - dragData.screenY; 900 901 // Move the dragged tab based on the mouse position. 902 let periphery = document.getElementById( 903 "tabbrowser-arrowscrollbox-periphery" 904 ); 905 let endEdge = ele => ele[screenAxis] + bounds(ele)[size]; 906 let endScreen = endEdge(draggedTab); 907 let startScreen = draggedTab[screenAxis]; 908 let { width: tabWidth, height: tabHeight } = bounds( 909 elementToMove(draggedTab) 910 ); 911 let tabSize = this._tabbrowserTabs.verticalMode ? tabHeight : tabWidth; 912 let shiftSize = tabSize; 913 dragData.tabWidth = tabWidth; 914 dragData.tabHeight = tabHeight; 915 dragData.translateX = translateX; 916 dragData.translateY = translateY; 917 let translate = screen - dragData[screenAxis]; 918 919 // Constrain the range over which the moving tabs can move between the edge of the tabstrip and periphery. 920 // Add 1 to periphery so we don't overlap it. 921 let startBound = this._rtlMode 922 ? endEdge(periphery) + 1 - startScreen 923 : this._tabbrowserTabs[screenAxis] - startScreen; 924 let endBound = this._rtlMode 925 ? endEdge(this._tabbrowserTabs) - endScreen 926 : periphery[screenAxis] - 1 - endScreen; 927 translate = Math.min(Math.max(translate, startBound), endBound); 928 929 // Center the tab under the cursor if the tab is not under the cursor while dragging 930 let draggedTabScreenAxis = draggedTab[screenAxis] + translate; 931 if ( 932 (screen < draggedTabScreenAxis || 933 screen > draggedTabScreenAxis + tabSize) && 934 draggedTabScreenAxis + tabSize < endBound && 935 draggedTabScreenAxis > startBound 936 ) { 937 translate = screen - draggedTab[screenAxis] - tabSize / 2; 938 // Ensure, after the above calculation, we are still within bounds 939 translate = Math.min(Math.max(translate, startBound), endBound); 940 } 941 942 if (!gBrowser.pinnedTabCount && !this._dragToPinPromoCard.shouldRender) { 943 let pinnedDropIndicatorMargin = parseFloat( 944 window.getComputedStyle(this._pinnedDropIndicator).marginInline 945 ); 946 this._checkWithinPinnedContainerBounds({ 947 firstMovingTabScreen: startScreen, 948 lastMovingTabScreen: endScreen, 949 pinnedTabsStartEdge: this._rtlMode 950 ? endEdge(this._tabbrowserTabs.arrowScrollbox) + 951 pinnedDropIndicatorMargin 952 : this._tabbrowserTabs[screenAxis], 953 pinnedTabsEndEdge: this._rtlMode 954 ? endEdge(this._tabbrowserTabs) 955 : this._tabbrowserTabs.arrowScrollbox[screenAxis] - 956 pinnedDropIndicatorMargin, 957 translate, 958 draggedTab, 959 }); 960 } 961 962 for (let item of movingTabs) { 963 item = elementToMove(item); 964 item.style.transform = `${translateAxis}(${translate}px)`; 965 } 966 967 dragData.translatePos = translate; 968 969 tabs = tabs.filter(t => !movingTabsSet.has(t) || t == draggedTab); 970 971 /** 972 * When the `draggedTab` is just starting to move, the `draggedTab` is in 973 * its original location and the `dropElementIndex == draggedTab.elementIndex`. 974 * Any tabs or tab group labels passed in as `item` will result in a 0 shift 975 * because all of those items should also continue to appear in their original 976 * locations. 977 * 978 * Once the `draggedTab` is more "backward" in the tab strip than its original 979 * position, any tabs or tab group labels between the `draggedTab`'s original 980 * `elementIndex` and the current `dropElementIndex` should shift "forward" 981 * out of the way of the dragging tabs. 982 * 983 * When the `draggedTab` is more "forward" in the tab strip than its original 984 * position, any tabs or tab group labels between the `draggedTab`'s original 985 * `elementIndex` and the current `dropElementIndex` should shift "backward" 986 * out of the way of the dragging tabs. 987 * 988 * @param {MozTabbrowserTab|MozTabbrowserTabGroup.label} item 989 * @param {number} dropElementIndex 990 * @returns {number} 991 */ 992 let getTabShift = (item, dropElementIndex) => { 993 if (item?.currentIndex == undefined) { 994 item.currentIndex = item.elementIndex; 995 } 996 if ( 997 item.currentIndex < draggedTab.elementIndex && 998 item.currentIndex >= dropElementIndex 999 ) { 1000 return this._rtlMode ? -shiftSize : shiftSize; 1001 } 1002 if ( 1003 item.currentIndex > draggedTab.elementIndex && 1004 item.currentIndex < dropElementIndex 1005 ) { 1006 return this._rtlMode ? shiftSize : -shiftSize; 1007 } 1008 return 0; 1009 }; 1010 1011 let oldDropElementIndex = 1012 dragData.animDropElementIndex ?? draggedTab.elementIndex; 1013 1014 /** 1015 * Returns the higher % by which one element overlaps another 1016 * in the tab strip. 1017 * 1018 * When element 1 is further forward in the tab strip: 1019 * 1020 * p1 p2 p1+s1 p2+s2 1021 * | | | | 1022 * --------------------------------- 1023 * ======================== 1024 * s1 1025 * =================== 1026 * s2 1027 * ========== 1028 * overlap 1029 * 1030 * When element 2 is further forward in the tab strip: 1031 * 1032 * p2 p1 p2+s2 p1+s1 1033 * | | | | 1034 * --------------------------------- 1035 * ======================== 1036 * s2 1037 * =================== 1038 * s1 1039 * ========== 1040 * overlap 1041 * 1042 * @param {number} p1 1043 * Position (x or y value in screen coordinates) of element 1. 1044 * @param {number} s1 1045 * Size (width or height) of element 1. 1046 * @param {number} p2 1047 * Position (x or y value in screen coordinates) of element 2. 1048 * @param {number} s2 1049 * Size (width or height) of element 1. 1050 * @returns {number} 1051 * Percent between 0.0 and 1.0 (inclusive) of element 1 or element 2 1052 * that is overlapped by the other element. If the elements have 1053 * different sizes, then this returns the larger overlap percentage. 1054 */ 1055 function greatestOverlap(p1, s1, p2, s2) { 1056 let overlapSize; 1057 if (p1 < p2) { 1058 // element 1 starts first 1059 overlapSize = p1 + s1 - p2; 1060 } else { 1061 // element 2 starts first 1062 overlapSize = p2 + s2 - p1; 1063 } 1064 1065 // No overlap if size is <= 0 1066 if (overlapSize <= 0) { 1067 return 0; 1068 } 1069 1070 // Calculate the overlap fraction from each element's perspective. 1071 let overlapPercent = Math.max(overlapSize / s1, overlapSize / s2); 1072 1073 return Math.min(overlapPercent, 1); 1074 } 1075 1076 /** 1077 * Determine what tab/tab group label we're dragging over. 1078 * 1079 * When dragging right or downwards, the reference point for overlap is 1080 * the right or bottom edge of the most forward moving tab. 1081 * 1082 * When dragging left or upwards, the reference point for overlap is the 1083 * left or top edge of the most backward moving tab. 1084 * 1085 * @returns {Element|null} 1086 * The tab or tab group label that should be used to visually shift tab 1087 * strip elements out of the way of the dragged tab(s) during a drag 1088 * operation. Note: this is not used to determine where the dragged 1089 * tab(s) will be dropped, it is only used for visual animation at this 1090 * time. 1091 */ 1092 let getOverlappedElement = () => { 1093 let point = (screenForward ? endScreen : startScreen) + translate; 1094 let low = 0; 1095 let high = tabs.length - 1; 1096 while (low <= high) { 1097 let mid = Math.floor((low + high) / 2); 1098 if (tabs[mid] == draggedTab && ++mid > high) { 1099 break; 1100 } 1101 let element = tabs[mid]; 1102 let elementForSize = elementToMove(element); 1103 screen = 1104 elementForSize[screenAxis] + 1105 getTabShift(element, oldDropElementIndex); 1106 1107 if (screen > point) { 1108 high = mid - 1; 1109 } else if (screen + bounds(elementForSize)[size] < point) { 1110 low = mid + 1; 1111 } else { 1112 return element; 1113 } 1114 } 1115 return null; 1116 }; 1117 1118 let dropElement = getOverlappedElement(); 1119 1120 let newDropElementIndex; 1121 if (dropElement) { 1122 newDropElementIndex = 1123 dropElement?.currentIndex ?? dropElement.elementIndex; 1124 } else { 1125 // When the dragged element(s) moves past a tab strip item, the dragged 1126 // element's leading edge starts dragging over empty space, resulting in 1127 // no overlapping `dropElement`. In these cases, try to fall back to the 1128 // previous animation drop element index to avoid unstable animations 1129 // (tab strip items snapping back and forth to shift out of the way of 1130 // the dragged element(s)). 1131 newDropElementIndex = oldDropElementIndex; 1132 1133 // We always want to have a `dropElement` so that we can determine where to 1134 // logically drop the dragged element(s). 1135 // 1136 // It's tempting to set `dropElement` to 1137 // `this.dragAndDropElements.at(oldDropElementIndex)`, and that is correct 1138 // for most cases, but there are edge cases: 1139 // 1140 // 1) the drop element index range needs to be one larger than the number of 1141 // items that can move in the tab strip. The simplest example is when all 1142 // tabs are ungrouped and unpinned: for 5 tabs, the drop element index needs 1143 // to be able to go from 0 (become the first tab) to 5 (become the last tab). 1144 // `this.dragAndDropElements.at(5)` would be `undefined` when dragging to the 1145 // end of the tab strip. In this specific case, it works to fall back to 1146 // setting the drop element to the last tab. 1147 // 1148 // 2) the `elementIndex` values of the tab strip items do not change during 1149 // the drag operation. When dragging the last tab or multiple tabs at the end 1150 // of the tab strip, having `dropElement` fall back to the last tab makes the 1151 // drop element one of the moving tabs. This can have some unexpected behavior 1152 // if not careful. Falling back to the last tab that's not moving (instead of 1153 // just the last tab) helps ensure that `dropElement` is always a stable target 1154 // to drop next to. 1155 // 1156 // 3) all of the elements in the tab strip are moving, in which case there can't 1157 // be a drop element and it should stay `undefined`. 1158 // 1159 // 4) we just started dragging and the `oldDropElementIndex` has its default 1160 // valuë of `movingTabs[0].elementIndex`. In this case, the drop element 1161 // shouldn't be a moving tab, so keep it `undefined`. 1162 let lastPossibleDropElement = this._rtlMode 1163 ? tabs.find(t => t != draggedTab) 1164 : tabs.findLast(t => t != draggedTab); 1165 let maxElementIndexForDropElement = 1166 lastPossibleDropElement?.currentIndex ?? 1167 lastPossibleDropElement?.elementIndex; 1168 if (Number.isInteger(maxElementIndexForDropElement)) { 1169 let index = Math.min( 1170 oldDropElementIndex, 1171 maxElementIndexForDropElement 1172 ); 1173 let oldDropElementCandidate = this._tabbrowserTabs.dragAndDropElements 1174 .filter(t => !movingTabsSet.has(t) || t == draggedTab) 1175 .at(index); 1176 if (!movingTabsSet.has(oldDropElementCandidate)) { 1177 dropElement = oldDropElementCandidate; 1178 } 1179 } 1180 } 1181 1182 let moveOverThreshold; 1183 let overlapPercent; 1184 let dropBefore; 1185 if (dropElement) { 1186 let dropElementForOverlap = elementToMove(dropElement); 1187 1188 let dropElementScreen = dropElementForOverlap[screenAxis]; 1189 let dropElementPos = 1190 dropElementScreen + getTabShift(dropElement, oldDropElementIndex); 1191 let dropElementSize = bounds(dropElementForOverlap)[size]; 1192 let firstMovingTabPos = startScreen + translate; 1193 overlapPercent = greatestOverlap( 1194 firstMovingTabPos, 1195 shiftSize, 1196 dropElementPos, 1197 dropElementSize 1198 ); 1199 1200 moveOverThreshold = gBrowser._tabGroupsEnabled 1201 ? Services.prefs.getIntPref( 1202 "browser.tabs.dragDrop.moveOverThresholdPercent" 1203 ) / 100 1204 : 0.5; 1205 moveOverThreshold = Math.min(1, Math.max(0, moveOverThreshold)); 1206 let shouldMoveOver = overlapPercent > moveOverThreshold; 1207 if (logicalForward && shouldMoveOver) { 1208 newDropElementIndex++; 1209 } else if (!logicalForward && !shouldMoveOver) { 1210 newDropElementIndex++; 1211 if (newDropElementIndex > oldDropElementIndex) { 1212 // FIXME: Not quite sure what's going on here, but this check 1213 // prevents jittery back-and-forth movement of background tabs 1214 // in certain cases. 1215 newDropElementIndex = oldDropElementIndex; 1216 } 1217 } 1218 1219 // Recalculate the overlap with the updated drop index for when the 1220 // drop element moves over. 1221 dropElementPos = 1222 dropElementScreen + getTabShift(dropElement, newDropElementIndex); 1223 overlapPercent = greatestOverlap( 1224 firstMovingTabPos, 1225 shiftSize, 1226 dropElementPos, 1227 dropElementSize 1228 ); 1229 dropBefore = firstMovingTabPos < dropElementPos; 1230 if (this._rtlMode) { 1231 dropBefore = !dropBefore; 1232 } 1233 1234 // If dragging a group over another group, don't make it look like it is 1235 // possible to drop the dragged group inside the other group. 1236 if ( 1237 isTabGroupLabel(draggedTab) && 1238 dropElement?.group && 1239 (!dropElement.group.collapsed || 1240 (dropElement.group.collapsed && dropElement.group.hasActiveTab)) 1241 ) { 1242 let overlappedGroup = dropElement.group; 1243 1244 if (isTabGroupLabel(dropElement)) { 1245 dropBefore = true; 1246 newDropElementIndex = 1247 dropElement?.currentIndex ?? dropElement.elementIndex; 1248 } else { 1249 dropBefore = false; 1250 let lastVisibleTabInGroup = 1251 overlappedGroup.tabsAndSplitViews.findLast(ele => ele.visible); 1252 newDropElementIndex = 1253 (lastVisibleTabInGroup?.currentIndex ?? 1254 lastVisibleTabInGroup.elementIndex) + 1; 1255 } 1256 1257 dropElement = overlappedGroup; 1258 } 1259 1260 // Constrain drop direction at the boundary between pinned and 1261 // unpinned tabs so that they don't mix together. 1262 let isOutOfBounds = isPinned 1263 ? dropElement.elementIndex >= numPinned 1264 : dropElement.elementIndex < numPinned; 1265 if (isOutOfBounds) { 1266 // Drop after last pinned tab 1267 dropElement = this._tabbrowserTabs.dragAndDropElements[numPinned - 1]; 1268 dropBefore = false; 1269 } 1270 } 1271 1272 if ( 1273 gBrowser._tabGroupsEnabled && 1274 isTab(draggedTab) && 1275 !isPinned && 1276 (!numPinned || newDropElementIndex >= numPinned) 1277 ) { 1278 let dragOverGroupingThreshold = 1 - moveOverThreshold; 1279 let groupingDelay = Services.prefs.getIntPref( 1280 "browser.tabs.dragDrop.createGroup.delayMS" 1281 ); 1282 1283 // When dragging tab(s) over an ungrouped tab, signal to the user 1284 // that dropping the tab(s) will create a new tab group. 1285 let shouldCreateGroupOnDrop = 1286 !movingTabsSet.has(dropElement) && 1287 isTab(dropElement) && 1288 !dropElement?.group && 1289 overlapPercent > dragOverGroupingThreshold; 1290 1291 // When dragging tab(s) over a collapsed tab group label, signal to the 1292 // user that dropping the tab(s) will add them to the group. 1293 let shouldDropIntoCollapsedTabGroup = 1294 isTabGroupLabel(dropElement) && 1295 dropElement.group.collapsed && 1296 overlapPercent > dragOverGroupingThreshold; 1297 1298 if (shouldCreateGroupOnDrop) { 1299 this._dragOverGroupingTimer = setTimeout(() => { 1300 this._triggerDragOverGrouping(dropElement); 1301 dragData.shouldCreateGroupOnDrop = true; 1302 this._setDragOverGroupColor(dragData.tabGroupCreationColor); 1303 }, groupingDelay); 1304 } else if (shouldDropIntoCollapsedTabGroup) { 1305 this._dragOverGroupingTimer = setTimeout(() => { 1306 this._triggerDragOverGrouping(dropElement); 1307 dragData.shouldDropIntoCollapsedTabGroup = true; 1308 this._setDragOverGroupColor(dropElement.group.color); 1309 }, groupingDelay); 1310 } else { 1311 this._tabbrowserTabs.removeAttribute("movingtab-group"); 1312 this._resetGroupTarget( 1313 document.querySelector("[dragover-groupTarget]") 1314 ); 1315 1316 delete dragData.shouldCreateGroupOnDrop; 1317 delete dragData.shouldDropIntoCollapsedTabGroup; 1318 1319 // Default to dropping into `dropElement`'s tab group, if it exists. 1320 let dropElementGroup = dropElement?.group; 1321 let colorCode = dropElementGroup?.color; 1322 1323 let lastUnmovingTabInGroup = dropElementGroup?.tabs.findLast( 1324 t => !movingTabsSet.has(t) 1325 ); 1326 if ( 1327 isTab(dropElement) && 1328 dropElementGroup && 1329 dropElement == lastUnmovingTabInGroup && 1330 !dropBefore && 1331 overlapPercent < dragOverGroupingThreshold 1332 ) { 1333 // Dragging tab over the last tab of a tab group, but not enough 1334 // for it to drop into the tab group. Drop it after the tab group instead. 1335 dropElement = dropElementGroup; 1336 colorCode = undefined; 1337 } else if (isTabGroupLabel(dropElement)) { 1338 if (dropBefore) { 1339 // Dropping right before the tab group. 1340 dropElement = dropElementGroup; 1341 colorCode = undefined; 1342 } else if (dropElementGroup.collapsed) { 1343 // Dropping right after the collapsed tab group. 1344 dropElement = dropElementGroup; 1345 colorCode = undefined; 1346 } else { 1347 // Dropping right before the first tab in the tab group. 1348 dropElement = dropElementGroup.tabs[0]; 1349 dropBefore = true; 1350 } 1351 } 1352 this._setDragOverGroupColor(colorCode); 1353 this._tabbrowserTabs.toggleAttribute( 1354 "movingtab-addToGroup", 1355 colorCode 1356 ); 1357 this._tabbrowserTabs.toggleAttribute("movingtab-ungroup", !colorCode); 1358 } 1359 } 1360 1361 if ( 1362 newDropElementIndex == oldDropElementIndex && 1363 dropBefore == dragData.dropBefore && 1364 dropElement == dragData.dropElement 1365 ) { 1366 return; 1367 } 1368 1369 dragData.dropElement = dropElement; 1370 dragData.dropBefore = dropBefore; 1371 dragData.animDropElementIndex = newDropElementIndex; 1372 1373 // Shift background tabs to leave a gap where the dragged tab 1374 // would currently be dropped. 1375 for (let item of tabs) { 1376 if (item == draggedTab) { 1377 continue; 1378 } 1379 let shift = getTabShift(item, newDropElementIndex); 1380 let transform = shift ? `${translateAxis}(${shift}px)` : ""; 1381 item = elementToMove(item); 1382 item.style.transform = transform; 1383 } 1384 } 1385 1386 _animateExpandedPinnedTabMove(event) { 1387 let draggedTab = event.dataTransfer.mozGetDataAt(TAB_DROP_TYPE, 0); 1388 let dragData = draggedTab._dragData; 1389 let movingTabs = dragData.movingTabs; 1390 1391 dragData.animLastScreenX ??= dragData.screenX; 1392 dragData.animLastScreenY ??= dragData.screenY; 1393 1394 let screenX = event.screenX; 1395 let screenY = event.screenY; 1396 1397 if ( 1398 screenY == dragData.animLastScreenY && 1399 screenX == dragData.animLastScreenX 1400 ) { 1401 return; 1402 } 1403 1404 let tabs = this._tabbrowserTabs.visibleTabs.slice( 1405 0, 1406 gBrowser.pinnedTabCount 1407 ); 1408 1409 dragData.animLastScreenY = screenY; 1410 dragData.animLastScreenX = screenX; 1411 1412 let { width: tabWidth, height: tabHeight } = 1413 draggedTab.getBoundingClientRect(); 1414 let shiftSizeX = tabWidth; 1415 let shiftSizeY = tabHeight; 1416 dragData.tabWidth = tabWidth; 1417 dragData.tabHeight = tabHeight; 1418 1419 // Move the dragged tab based on the mouse position. 1420 let periphery = document.getElementById( 1421 "tabbrowser-arrowscrollbox-periphery" 1422 ); 1423 let endScreenX = draggedTab.screenX + tabWidth; 1424 let endScreenY = draggedTab.screenY + tabHeight; 1425 let startScreenX = draggedTab.screenX; 1426 let startScreenY = draggedTab.screenY; 1427 let translateX = screenX - dragData.screenX; 1428 let translateY = screenY - dragData.screenY; 1429 let startBoundX = this._tabbrowserTabs.screenX - startScreenX; 1430 let startBoundY = this._tabbrowserTabs.screenY - startScreenY; 1431 let endBoundX = 1432 this._tabbrowserTabs.screenX + 1433 window.windowUtils.getBoundsWithoutFlushing(this._tabbrowserTabs) 1434 .width - 1435 endScreenX; 1436 let endBoundY = periphery.screenY - endScreenY; 1437 translateX = Math.min(Math.max(translateX, startBoundX), endBoundX); 1438 translateY = Math.min(Math.max(translateY, startBoundY), endBoundY); 1439 1440 // Center the tab under the cursor if the tab is not under the cursor while dragging 1441 if ( 1442 screen < draggedTab.screenY + translateY || 1443 screen > draggedTab.screenY + tabHeight + translateY 1444 ) { 1445 translateY = screen - draggedTab.screenY - tabHeight / 2; 1446 } 1447 1448 for (let tab of movingTabs) { 1449 tab.style.transform = `translate(${translateX}px, ${translateY}px)`; 1450 } 1451 1452 dragData.translateX = translateX; 1453 dragData.translateY = translateY; 1454 1455 // Determine what tab we're dragging over. 1456 // * Single tab dragging: Point of reference is the center of the dragged tab. If that 1457 // point touches a background tab, the dragged tab would take that 1458 // tab's position when dropped. 1459 // * Multiple tabs dragging: Tabs are stacked, so we can still use the above 1460 // point of reference, the center of the dragged tab. 1461 // * We're doing a binary search in order to reduce the amount of 1462 // tabs we need to check. 1463 1464 tabs = tabs.filter(t => !movingTabs.includes(t) || t == draggedTab); 1465 let tabCenterX = startScreenX + translateX + tabWidth / 2; 1466 let tabCenterY = startScreenY + translateY + tabHeight / 2; 1467 1468 let shiftNumber = this._maxTabsPerRow - 1; 1469 1470 let getTabShift = (tab, dropIndex) => { 1471 if (tab?.currentIndex == undefined) { 1472 tab.currentIndex = tab.elementIndex; 1473 } 1474 if ( 1475 tab.currentIndex < draggedTab.elementIndex && 1476 tab.currentIndex >= dropIndex 1477 ) { 1478 // If tab is at the end of a row, shift back and down 1479 let tabRow = Math.ceil((tab.currentIndex + 1) / this._maxTabsPerRow); 1480 let shiftedTabRow = Math.ceil( 1481 (tab.currentIndex + 2) / this._maxTabsPerRow 1482 ); 1483 if (tab.currentIndex && tabRow != shiftedTabRow) { 1484 return [ 1485 RTL_UI ? tabWidth * shiftNumber : -tabWidth * shiftNumber, 1486 shiftSizeY, 1487 ]; 1488 } 1489 return [RTL_UI ? -shiftSizeX : shiftSizeX, 0]; 1490 } 1491 if ( 1492 tab.currentIndex > draggedTab.elementIndex && 1493 tab.currentIndex < dropIndex 1494 ) { 1495 // If tab is not index 0 and at the start of a row, shift across and up 1496 let tabRow = Math.floor(tab.currentIndex / this._maxTabsPerRow); 1497 let shiftedTabRow = Math.floor( 1498 (tab.currentIndex - 1) / this._maxTabsPerRow 1499 ); 1500 if (tab.currentIndex && tabRow != shiftedTabRow) { 1501 return [ 1502 RTL_UI ? -tabWidth * shiftNumber : tabWidth * shiftNumber, 1503 -shiftSizeY, 1504 ]; 1505 } 1506 return [RTL_UI ? shiftSizeX : -shiftSizeX, 0]; 1507 } 1508 return [0, 0]; 1509 }; 1510 1511 let low = 0; 1512 let high = tabs.length - 1; 1513 let newIndex = -1; 1514 let oldIndex = dragData.animDropElementIndex ?? draggedTab.elementIndex; 1515 1516 while (low <= high) { 1517 let mid = Math.floor((low + high) / 2); 1518 if (tabs[mid] == draggedTab && ++mid > high) { 1519 break; 1520 } 1521 let [shiftX, shiftY] = getTabShift(tabs[mid], oldIndex); 1522 screenX = tabs[mid].screenX + shiftX; 1523 screenY = tabs[mid].screenY + shiftY; 1524 1525 if (screenY + tabHeight < tabCenterY) { 1526 low = mid + 1; 1527 } else if (screenY > tabCenterY) { 1528 high = mid - 1; 1529 } else if ( 1530 RTL_UI ? screenX + tabWidth < tabCenterX : screenX > tabCenterX 1531 ) { 1532 high = mid - 1; 1533 } else if ( 1534 RTL_UI ? screenX > tabCenterX : screenX + tabWidth < tabCenterX 1535 ) { 1536 low = mid + 1; 1537 } else { 1538 newIndex = tabs[mid].currentIndex; 1539 break; 1540 } 1541 } 1542 1543 if (newIndex >= oldIndex && newIndex < tabs.length) { 1544 newIndex++; 1545 } 1546 1547 if (newIndex < 0) { 1548 newIndex = oldIndex; 1549 } 1550 1551 if (newIndex == dragData.animDropElementIndex) { 1552 return; 1553 } 1554 1555 dragData.animDropElementIndex = newIndex; 1556 dragData.dropElement = tabs[Math.min(newIndex, tabs.length - 1)]; 1557 dragData.dropBefore = newIndex < tabs.length; 1558 1559 // Shift background tabs to leave a gap where the dragged tab 1560 // would currently be dropped. 1561 for (let tab of tabs) { 1562 if (tab != draggedTab) { 1563 let [shiftX, shiftY] = getTabShift(tab, newIndex); 1564 tab.style.transform = 1565 shiftX || shiftY ? `translate(${shiftX}px, ${shiftY}px)` : ""; 1566 } 1567 } 1568 } 1569 1570 // If the tab is dropped in another window, we need to pass in the original window document 1571 _resetTabsAfterDrop(draggedTabDocument = document) { 1572 if (this._tabbrowserTabs.expandOnHover) { 1573 // Re-enable MousePosTracker after dropping 1574 MousePosTracker.addListener(document.defaultView.SidebarController); 1575 } 1576 1577 let pinnedDropIndicator = draggedTabDocument.getElementById( 1578 "pinned-drop-indicator" 1579 ); 1580 let draggedTabContainer = 1581 draggedTabDocument.ownerGlobal.gBrowser.tabContainer; 1582 pinnedDropIndicator.removeAttribute("visible"); 1583 pinnedDropIndicator.removeAttribute("interactive"); 1584 draggedTabContainer.style.maxWidth = ""; 1585 let allTabs = draggedTabDocument.getElementsByClassName("tabbrowser-tab"); 1586 for (let tab of allTabs) { 1587 tab.style.width = ""; 1588 tab.style.left = ""; 1589 tab.style.top = ""; 1590 tab.style.maxWidth = ""; 1591 tab.style.pointerEvents = ""; 1592 tab.removeAttribute("dragtarget"); 1593 tab.removeAttribute("small-stack"); 1594 tab.removeAttribute("big-stack"); 1595 } 1596 for (let label of draggedTabDocument.getElementsByClassName( 1597 "tab-group-label-container" 1598 )) { 1599 label.style.width = ""; 1600 label.style.maxWidth = ""; 1601 label.style.height = ""; 1602 label.style.left = ""; 1603 label.style.top = ""; 1604 label.style.pointerEvents = ""; 1605 label.removeAttribute("dragtarget"); 1606 } 1607 for (let label of draggedTabContainer.getElementsByClassName( 1608 "tab-group-label" 1609 )) { 1610 delete label.currentIndex; 1611 } 1612 let periphery = draggedTabDocument.getElementById( 1613 "tabbrowser-arrowscrollbox-periphery" 1614 ); 1615 periphery.style.marginBlockStart = ""; 1616 periphery.style.marginInlineStart = ""; 1617 periphery.style.left = ""; 1618 periphery.style.top = ""; 1619 let pinnedTabsContainer = draggedTabDocument.getElementById( 1620 "pinned-tabs-container" 1621 ); 1622 let pinnedPeriphery = draggedTabDocument.getElementById( 1623 "pinned-tabs-container-periphery" 1624 ); 1625 pinnedPeriphery && pinnedTabsContainer.removeChild(pinnedPeriphery); 1626 pinnedTabsContainer.removeAttribute("dragActive"); 1627 pinnedTabsContainer.style.minHeight = ""; 1628 draggedTabDocument.defaultView.SidebarController.updatePinnedTabsHeightOnResize(); 1629 pinnedTabsContainer.scrollbox.style.height = ""; 1630 pinnedTabsContainer.scrollbox.style.width = ""; 1631 let arrowScrollbox = draggedTabDocument.getElementById( 1632 "tabbrowser-arrowscrollbox" 1633 ); 1634 arrowScrollbox.scrollbox.style.height = ""; 1635 arrowScrollbox.scrollbox.style.width = ""; 1636 for (let groupLabel of draggedTabContainer.getElementsByClassName( 1637 "tab-group-label-container" 1638 )) { 1639 groupLabel.style.left = ""; 1640 groupLabel.style.top = ""; 1641 } 1642 for (let splitviewWrapper of draggedTabContainer.getElementsByTagName( 1643 "tab-split-view-wrapper" 1644 )) { 1645 splitviewWrapper.style.width = ""; 1646 splitviewWrapper.style.maxWidth = ""; 1647 splitviewWrapper.style.height = ""; 1648 splitviewWrapper.style.left = ""; 1649 splitviewWrapper.style.top = ""; 1650 splitviewWrapper.style.pointerEvents = ""; 1651 splitviewWrapper.removeAttribute("dragtarget"); 1652 } 1653 } 1654 }; 1655 }