From b21e442c4f5fcb109db5b66cdeff6d5fafbbfd7a Mon Sep 17 00:00:00 2001 From: abaevbog Date: Mon, 29 Sep 2025 11:28:30 -0700 Subject: [PATCH] maintain the size of tabs as they are being closed (#5548) When a tab is closed via clicks on "X" button, record and lock all tabs' widths. That way, the "X" button of the tab that follows the one that was just deleted will land under the mouse cursor, allowing one to delete many tabs. In addition, preserve the scroll position of the tab bar. If it's impossible because too many tabs were deleted, add a spacer after the last tab. Fixes: #5523 --- chrome/content/zotero/components/tabBar.jsx | 53 +++++++++++++++++++-- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/chrome/content/zotero/components/tabBar.jsx b/chrome/content/zotero/components/tabBar.jsx index 98b1897ddb..c9fcd34676 100644 --- a/chrome/content/zotero/components/tabBar.jsx +++ b/chrome/content/zotero/components/tabBar.jsx @@ -33,7 +33,7 @@ const { CSSIcon, CSSItemTypeIcon } = require('./icons'); const SCROLL_ARROW_SCROLL_BY = 222; const Tab = memo((props) => { - const { icon, id, index, isBeingDragged, isItemType, onContextMenu, onDragEnd, onDragStart, onTabClick, onTabClose, onTabMouseDown, selected, title, renderTitle } = props; + const { icon, id, index, isBeingDragged, isItemType, onContextMenu, onDragEnd, onDragStart, onTabClick, onTabClose, onTabMouseDown, selected, title, renderTitle, width } = props; const handleTabMouseDown = useCallback(event => onTabMouseDown(event, id), [onTabMouseDown, id]); const handleContextMenu = useCallback(event => onContextMenu(event, id), [onContextMenu, id]); @@ -66,6 +66,7 @@ const Tab = memo((props) => { onDragStart={handleDragStart} onDragEnd={onDragEnd} tabIndex="-1" + style={ width ? { maxWidth: `${width}px` } : {}} > { isItemType ? @@ -100,6 +101,7 @@ Tab.propTypes = { selected: PropTypes.bool.isRequired, title: PropTypes.string.isRequired, renderTitle: PropTypes.bool, + width: PropTypes.number }; @@ -107,10 +109,12 @@ const TabBar = forwardRef(function (props, ref) { const [tabs, setTabs] = useState([]); const [dragging, setDragging] = useState(false); const [dragMouseX, setDragMouseX] = useState(0); + const [lockedTabsState, setLockedTabsState] = useState(new Map()); const dragIDRef = useRef(null); const dragGrabbedDeltaXRef = useRef(); const tabsInnerContainerRef = useRef(); const tabsRef = useRef(); + const spacerRef = useRef(); const startArrowRef = useRef(); const endArrowRef = useRef(); // Used to throttle mouse movement @@ -133,10 +137,11 @@ const TabBar = forwardRef(function (props, ref) { useEffect(() => { // Scroll selected tab into view let selectedTabNode = tabsInnerContainerRef.current.querySelector(".tab.selected"); - if (!selectedTabNode || dragging) return; + if (!selectedTabNode || dragging || lockedTabsState.size) return; selectedTabNode.scrollIntoView({ behavior: 'smooth' }); }, [tabs]); + useLayoutEffect(preserveScrollPosition, [tabs, lockedTabsState]); useLayoutEffect(updateScrollArrows); useLayoutEffect(updateOverflowing, [tabs]); @@ -170,6 +175,7 @@ const TabBar = forwardRef(function (props, ref) { }); function updateScrollArrows() { + if (lockedTabsState.size) return; let scrollable = tabsRef.current.scrollWidth !== tabsRef.current.clientWidth; if (scrollable) { tabsInnerContainerRef.current.classList.add('scrollable'); @@ -198,6 +204,23 @@ const TabBar = forwardRef(function (props, ref) { tabNameDOM.classList.toggle('overflowing', tabNameDOM.scrollWidth > tabNameDOM.clientWidth); }); } + + // Preserve the relative scroll position when tabs are being closed. + // (set in handleTabClose) + function preserveScrollPosition() { + let desiredScrollLeft = lockedTabsState.get("scroll-left"); + if (!desiredScrollLeft) return; + tabsRef.current.scrollLeft = desiredScrollLeft; + // If there are not many tabs left, there may be not enough scrollable + // space to keep the original scrollLeft position. Add a spacer at the end and try again. + if (tabsRef.current.scrollLeft !== desiredScrollLeft) { + let spacerWidth = parseInt(spacerRef.current.style.minWidth) || 0; + let columnGap = 4; + let tabWidth = lockedTabsState.get("tab") || 0; + spacerRef.current.style.minWidth = `${spacerWidth + tabWidth + columnGap}px`; + tabsRef.current.scrollLeft = desiredScrollLeft; + } + } const handleTabMouseDown = useCallback((event, id) => { // Don't select tab if it'll be closed with middle button click on mouse up @@ -313,10 +336,29 @@ const TabBar = forwardRef(function (props, ref) { }, [props.onTabMove]); const handleTabClose = useCallback((event, id) => { + // When a tab is being closed, lock the widths of tabs and scroll position, + // so that the next tab's "X" button also ends up under the mouse + if (!lockedTabsState.size) { + let tabElements = [...tabsInnerContainerRef.current.querySelectorAll(".tab")].filter(tab => tab.offsetWidth); + let zoteroPaneTab = tabElements.find(tab => tab.dataset.id === "zotero-pane"); + let tab = tabElements.find(tab => tab.dataset.id !== "zotero-pane"); + setLockedTabsState(new Map([ + ["zotero-pane", zoteroPaneTab.offsetWidth], + ["tab", tab.offsetWidth], + ["scroll-left", tabsRef.current.scrollLeft] + ])); + } props.onTabClose(id); event.stopPropagation(); - }, [props.onTabClose]); - + }, [props.onTabClose, lockedTabsState]); + + // When the mouse leaves the tab bar, un-freeze the width and positioning + // of tabs that are locked when tabs are being closed. + const handleTabBarMouseLeave = useCallback(() => { + if (!lockedTabsState.size) return; + setLockedTabsState(new Map()); + spacerRef.current.style.removeProperty("min-width"); + }, [lockedTabsState]); const handleWheel = useCallback((event) => { // Normalize wheel speed @@ -359,6 +401,7 @@ const TabBar = forwardRef(function (props, ref) { ref={tabsInnerContainerRef} className="tab-bar-inner-container" onWheel={handleWheel} + onMouseLeave={handleTabBarMouseLeave} >
)} +