diff --git a/src/zen/workspaces/ZenWorkspaces.mjs b/src/zen/workspaces/ZenWorkspaces.mjs index 64d39b63cf..1ef2b1bcd8 100644 --- a/src/zen/workspaces/ZenWorkspaces.mjs +++ b/src/zen/workspaces/ZenWorkspaces.mjs @@ -41,6 +41,18 @@ class nsZenWorkspaces { _workspaceCache = []; #lastScrollTime = 0; + #lastHorizontalWheelEventTime = 0; + #lastTrackpadSwipeEventTime = 0; + #trackpadPostSwipeSuppressionMs = 2000; + #horizontalScrollAccumulator = 0; + #horizontalScrollFinalizeTimer = null; + #horizontalWheelGestureActive = false; + #horizontalWheelSnapThreshold = 55; + // Trackpad swipe deltas are cumulative; keep explicit state so slow gestures + // and mid-gesture reversals stay stable and deterministic. + #trackpadLastEventDelta = null; + #trackpadLastMotionDirection = 0; + #trackpadMotionBaseline = null; bookmarkMenus = [ "PlacesToolbar", @@ -523,17 +535,28 @@ class nsZenWorkspaces { if (!this.workspaceEnabled || !gNavToolbox.matches(":hover")) { return; } + if (Date.now() - this.#lastTrackpadSwipeEventTime < this.#trackpadPostSwipeSuppressionMs) { + return; + } + // Some devices emit AppCommand and wheel for the same horizontal wheel action. + // Ignore AppCommand if a horizontal wheel event just occurred. + if (Date.now() - this.#lastHorizontalWheelEventTime < 250) { + return; + } + if (this.#horizontalWheelGestureActive) { + this.#cancelHorizontalWheelGesture(); + } - const direction = this.naturalScroll ? -1 : 1; + const direction = this.naturalScroll ? 1 : -1; // event is forward or back switch (event.command) { case "Forward": - this.changeWorkspaceShortcut(1 * direction); + this.changeWorkspaceShortcut(1 * direction, true); event.stopImmediatePropagation(); event.preventDefault(); break; case "Back": - this.changeWorkspaceShortcut(-1 * direction); + this.changeWorkspaceShortcut(-1 * direction, true); event.stopImmediatePropagation(); event.preventDefault(); break; @@ -548,8 +571,15 @@ class nsZenWorkspaces { #setupSidebarHandlers() { const toolbox = gNavToolbox; - const scrollCooldown = 200; // Milliseconds to wait before allowing another scroll - const scrollThreshold = 1; // Minimum scroll delta to trigger workspace change + const verticalScrollCooldown = 200; // Milliseconds to wait before allowing another scroll + const verticalScrollThreshold = 1; + const horizontalWheelSensitivity = 1.1; + // Keep settle timing fast for wheel UX, while still respecting platform + // transaction timing as an upper bound. + const horizontalGestureFinalizeDelay = Math.min( + Services.prefs.getIntPref("mousewheel.transaction.timeout", 150), + 95 + ); toolbox.addEventListener( "wheel", @@ -558,12 +588,20 @@ class nsZenWorkspaces { return; } - // Only process non-gesture scrolls - if (event.deltaMode !== 1) { + const absDeltaX = Math.abs(event.deltaX); + const absDeltaY = Math.abs(event.deltaY); + const isHorizontalScroll = absDeltaX > 0 && absDeltaX >= absDeltaY; + const isVerticalScroll = absDeltaY > 0 && absDeltaY > absDeltaX; + + if (!isHorizontalScroll && !isVerticalScroll) { return; } - const isVerticalScroll = event.deltaY && !event.deltaX; + // Horizontal mouse wheels can report pixel deltas, so only enforce + // line-based deltas for vertical scrolling. + if (!isHorizontalScroll && event.deltaMode !== 1) { + return; + } //if the scroll is vertical this checks that a modifier key is used before proceeding if (isVerticalScroll) { @@ -582,20 +620,53 @@ class nsZenWorkspaces { } } - let currentTime = Date.now(); - if (currentTime - this.#lastScrollTime < scrollCooldown) { + const currentTime = Date.now(); + + if (isHorizontalScroll) { + if ( + event.deltaMode === event.DOM_DELTA_PIXEL && + currentTime - this.#lastTrackpadSwipeEventTime < this.#trackpadPostSwipeSuppressionMs + ) { + return; + } + const scrollDirection = this.naturalScroll ? 1 : -1; + const deltaPixels = + this.#normalizeHorizontalWheelDelta(event) * + scrollDirection * + horizontalWheelSensitivity; + if (!deltaPixels) { + return; + } + this.#lastHorizontalWheelEventTime = currentTime; + if (this.#inChangingWorkspace) { + return; + } + this.#startHorizontalWheelGesture(); + this.#horizontalScrollAccumulator += deltaPixels; + const translateX = this.#applyHorizontalSwipeDelta(deltaPixels); + if (Math.abs(translateX) >= this.#horizontalWheelSnapThreshold) { + if (this.#horizontalScrollFinalizeTimer) { + clearTimeout(this.#horizontalScrollFinalizeTimer); + this.#horizontalScrollFinalizeTimer = null; + } + void this.#finalizeHorizontalWheelGesture(true); + } else { + this.#scheduleHorizontalWheelGestureFinalize(horizontalGestureFinalizeDelay); + } return; } - //this decides which delta to use - const delta = isVerticalScroll ? event.deltaY : event.deltaX; - if (Math.abs(delta) < scrollThreshold) { + if (this.#horizontalWheelGestureActive) { + this.#cancelHorizontalWheelGesture(); + } + if (currentTime - this.#lastScrollTime < verticalScrollCooldown) { + return; + } + if (Math.abs(event.deltaY) < verticalScrollThreshold) { return; } - // Determine scroll direction - let rawDirection = delta > 0 ? 1 : -1; - + const rawDirection = event.deltaY > 0 ? 1 : -1; let direction = this.naturalScroll ? -1 : 1; this.changeWorkspaceShortcut(rawDirection * direction); @@ -605,6 +676,113 @@ class nsZenWorkspaces { ); } + #startHorizontalWheelGesture() { + if (this.#horizontalWheelGestureActive) { + return; + } + this.#horizontalWheelGestureActive = true; + gZenFolders.cancelPopupTimer(); + document.documentElement.setAttribute("swipe-gesture", "true"); + document.addEventListener("popupshown", this.popupOpenHandler, { once: true }); + this._swipeState = { + isGestureActive: true, + lastDelta: 0, + direction: null, + }; + Services.prefs.setBoolPref("zen.swipe.is-fast-swipe", true); + } + + #normalizeHorizontalWheelDelta(event) { + switch (event.deltaMode) { + case event.DOM_DELTA_LINE: + return event.deltaX * 40; + case event.DOM_DELTA_PAGE: + return event.deltaX * 160; + case event.DOM_DELTA_PIXEL: + default: + return event.deltaX * 0.35; + } + } + + #applyHorizontalSwipeDelta(deltaPixels) { + const stripWidth = + window.windowUtils.getBoundsWithoutFlushing(document.getElementById("navigator-toolbox")) + .width + + window.windowUtils.getBoundsWithoutFlushing(document.getElementById("zen-sidebar-splitter")) + .width * + 2; + // Keep an explicit wheel accumulator so low-amplitude wheel input still + // feels continuous instead of stepping between discrete event deltas. + let translateX = this.#horizontalScrollAccumulator; + // Match workspace swipe resistance so wheel and gesture interactions feel consistent. + const forceMultiplier = Math.max(0.5, 1 - Math.abs(translateX) / (stripWidth * 4.5)); + translateX *= forceMultiplier; + + if (Math.abs(deltaPixels) > 0.8) { + this._swipeState.direction = deltaPixels > 0 ? "left" : "right"; + } + + const currentWorkspace = this.getActiveWorkspaceFromCache(); + this._organizeWorkspaceStripLocations(currentWorkspace, true, translateX); + return translateX; + } + + #scheduleHorizontalWheelGestureFinalize(finalizeDelay) { + if (this.#horizontalScrollFinalizeTimer) { + clearTimeout(this.#horizontalScrollFinalizeTimer); + } + this.#horizontalScrollFinalizeTimer = setTimeout(() => { + this.#horizontalScrollFinalizeTimer = null; + void this.#finalizeHorizontalWheelGesture(); + }, finalizeDelay); + } + + async #finalizeHorizontalWheelGesture(forceSwitch = false) { + if (!this.#horizontalWheelGestureActive) { + return; + } + const swipeDelta = this.#horizontalScrollAccumulator; + const shouldSwitch = forceSwitch || Math.abs(swipeDelta) >= this.#horizontalWheelSnapThreshold; + const workspaceOffset = swipeDelta > 0 ? -1 : 1; + this.#horizontalScrollAccumulator = 0; + if (shouldSwitch) { + await this.changeWorkspaceShortcut(workspaceOffset, true); + this.#lastScrollTime = Date.now(); + } else { + this._cancelSwipeAnimation(); + } + this.#cleanupHorizontalWheelGesture(); + } + + #cancelHorizontalWheelGesture() { + if (!this.#horizontalWheelGestureActive) { + return; + } + if (this.#horizontalScrollFinalizeTimer) { + clearTimeout(this.#horizontalScrollFinalizeTimer); + this.#horizontalScrollFinalizeTimer = null; + } + this.#horizontalScrollAccumulator = 0; + this._cancelSwipeAnimation(); + this.#cleanupHorizontalWheelGesture(); + } + + #cleanupHorizontalWheelGesture() { + this.#horizontalWheelGestureActive = false; + this._swipeState = { + isGestureActive: false, + lastDelta: 0, + direction: null, + }; + Services.prefs.setBoolPref("zen.swipe.is-fast-swipe", false); + document.documentElement.removeAttribute("swipe-gesture"); + gZenUIManager.tabsWrapper.style.removeProperty("scrollbar-width"); + document.documentElement.style.setProperty("--zen-background-opacity", "1"); + delete this._hasAnimatedBackgrounds; + this.updateTabsContainers(); + document.removeEventListener("popupshown", this.popupOpenHandler, { once: true }); + } + initializeGestureHandlers() { const elements = [ gNavToolbox, @@ -633,6 +811,7 @@ class nsZenWorkspaces { element.addEventListener( "MozSwipeGestureEnd", () => { + this.#lastTrackpadSwipeEventTime = Date.now(); Services.prefs.setBoolPref("zen.swipe.is-fast-swipe", false); document.documentElement.removeAttribute("swipe-gesture"); gZenUIManager.tabsWrapper.style.removeProperty("scrollbar-width"); @@ -648,6 +827,12 @@ class nsZenWorkspaces { _popupOpenHandler() { // If a popup is opened, we should stop the swipe gesture if (this._swipeState?.isGestureActive) { + if (this.#horizontalScrollFinalizeTimer) { + clearTimeout(this.#horizontalScrollFinalizeTimer); + this.#horizontalScrollFinalizeTimer = null; + } + this.#horizontalWheelGestureActive = false; + this.#horizontalScrollAccumulator = 0; document.documentElement.removeAttribute("swipe-gesture"); gZenUIManager.tabsWrapper.style.removeProperty("scrollbar-width"); this.updateTabsContainers(); @@ -656,7 +841,10 @@ class nsZenWorkspaces { } _handleSwipeMayStart(event) { - if (this.privateWindowOrDisabled || this.#inChangingWorkspace) { + this.#lastTrackpadSwipeEventTime = Date.now(); + // Allow new swipe intent even while a previous workspace transition settles, + // otherwise reversing direction feels blocked until finger lift. + if (this.privateWindowOrDisabled) { return; } if ( @@ -677,6 +865,7 @@ class nsZenWorkspaces { } _handleSwipeStart(event) { + this.#lastTrackpadSwipeEventTime = Date.now(); if (!this.workspaceEnabled) { return; } @@ -693,10 +882,14 @@ class nsZenWorkspaces { lastDelta: 0, direction: null, }; + this.#trackpadLastEventDelta = null; + this.#trackpadLastMotionDirection = 0; + this.#trackpadMotionBaseline = null; Services.prefs.setBoolPref("zen.swipe.is-fast-swipe", true); } _handleSwipeUpdate(event) { + this.#lastTrackpadSwipeEventTime = Date.now(); if (!this.workspaceEnabled || !this._swipeState?.isGestureActive) { return; } @@ -704,44 +897,71 @@ class nsZenWorkspaces { event.preventDefault(); event.stopPropagation(); - const delta = event.delta * 300; + const rawDelta = event.delta * 300; + if (this.#trackpadMotionBaseline === null) { + this.#trackpadMotionBaseline = rawDelta; + } + let delta = rawDelta - this.#trackpadMotionBaseline; + let stepDelta = 0; + if (typeof this.#trackpadLastEventDelta === "number") { + stepDelta = rawDelta - this.#trackpadLastEventDelta; + } + this.#trackpadLastEventDelta = rawDelta; + + // Keep direction detection responsive at low swipe velocities so reversing + // mid-gesture does not stall around the strip center. + const motionStepThreshold = 0.35; + if (Math.abs(stepDelta) >= motionStepThreshold) { + const stepDirection = stepDelta > 0 ? 1 : -1; + this.#trackpadLastMotionDirection = stepDirection; + this._swipeState.direction = stepDirection > 0 ? "left" : "right"; + } const stripWidth = window.windowUtils.getBoundsWithoutFlushing(document.getElementById("navigator-toolbox")) .width + window.windowUtils.getBoundsWithoutFlushing(document.getElementById("zen-sidebar-splitter")) .width * 2; - let translateX = this._swipeState.lastDelta + delta; + let translateX = delta; // Add a force multiplier as we are translating the strip depending on how close to the edge we are let forceMultiplier = Math.min(1, 1 - Math.abs(translateX) / (stripWidth * 4.5)); // 4.5 instead of 4 to add a bit of a buffer if (forceMultiplier > 0.5) { translateX *= forceMultiplier; - this._swipeState.lastDelta = delta + (translateX - delta) * 0.5; + this._swipeState.lastDelta = translateX; } else { translateX = this._swipeState.lastDelta; } - if (Math.abs(delta) > 0.8) { - this._swipeState.direction = delta > 0 ? "left" : "right"; - } - // Apply a translateX to the tab strip to give the user feedback on the swipe const currentWorkspace = this.getActiveWorkspaceFromCache(); this._organizeWorkspaceStripLocations(currentWorkspace, true, translateX); } async _handleSwipeEnd(event) { + this.#lastTrackpadSwipeEventTime = Date.now(); if (!this.workspaceEnabled) { return; } event.preventDefault(); event.stopPropagation(); const isRTL = document.documentElement.matches(":-moz-locale-dir(rtl)"); - const moveForward = (event.direction === SimpleGestureEvent.DIRECTION_RIGHT) !== isRTL; + let swipeDirection = event.direction; + if (this.#trackpadLastMotionDirection < 0) { + swipeDirection = SimpleGestureEvent.DIRECTION_RIGHT; + } else if (this.#trackpadLastMotionDirection > 0) { + swipeDirection = SimpleGestureEvent.DIRECTION_LEFT; + } + const moveForward = (swipeDirection === SimpleGestureEvent.DIRECTION_RIGHT) !== isRTL; const rawDirection = moveForward ? 1 : -1; const direction = this.naturalScroll ? -1 : 1; + const previousWorkspaceId = this.activeWorkspace; await this.changeWorkspaceShortcut(rawDirection * direction, true); + if (this.activeWorkspace === previousWorkspaceId) { + // Ensure the strip recenters when a swipe gesture resolves without a committed + // workspace change (for example while another transition is still settling). + this._cancelSwipeAnimation(); + } // Reset swipe state this._swipeState = { @@ -749,6 +969,9 @@ class nsZenWorkspaces { lastDelta: 0, direction: null, }; + this.#trackpadLastEventDelta = null; + this.#trackpadLastMotionDirection = 0; + this.#trackpadMotionBaseline = null; } get activeWorkspace() {