diff --git a/src/zen/compact-mode/ZenCompactMode.mjs b/src/zen/compact-mode/ZenCompactMode.mjs index c6ea9a190c3..66e194b79e5 100644 --- a/src/zen/compact-mode/ZenCompactMode.mjs +++ b/src/zen/compact-mode/ZenCompactMode.mjs @@ -371,8 +371,211 @@ window.gZenCompactModeManager = { window.dispatchEvent(new CustomEvent("ZenCompactMode:LayoutUpdate")); }, + get _canAnimateSidebar() { + return ( + lazy.COMPACT_MODE_CAN_ANIMATE_SIDEBAR && + !this.isSidebarPotentiallyOpen() && + !this._preventAnimateCollapse && + typeof this._wasInCompactMode === "undefined" + ); + }, + + _prepareToolbarTransition() { + if ( + AppConstants.platform !== "macosx" || + document.documentElement.matches(":dir(rtl)") || + gZenVerticalTabsManager._hasSetSingleToolbar || + !this.canHideSidebar || + this.canHideToolbar || + !this._canAnimateSidebar + ) { + return null; + } + const controls = [ + gZenVerticalTabsManager.actualWindowButtons, + document.getElementById("zen-sidebar-top-buttons-customization-target"), + ]; + const titlebar = document.getElementById("titlebar"); + const navbar = document.getElementById("nav-bar"); + const navbarContents = document.getElementById( + "nav-bar-customization-target" + ); + const header = document.getElementById("zen-sidebar-top-buttons"); + const styles = [ + ...controls.map(element => [element, "transform"]), + [titlebar, "margin-top"], + [navbarContents, "margin-left"], + [navbar, "margin-left"], + [navbar, "margin-right"], + [header, "margin-left"], + ].map(([element, property]) => ({ + element, + property, + value: element.style.getPropertyValue(property), + priority: element.style.getPropertyPriority(property), + })); + const bounds = controls.map(element => element.getBoundingClientRect()); + const hiding = !this.preference; + const initialNavbarX = navbarContents.getBoundingClientRect().x; + const sidebarExpanded = gZenVerticalTabsManager._prefsSidebarExpanded; + const rightSide = this.sidebarIsOnRight; + const sidebar = this.sidebar; + const animations = []; + // Reserve the header's height so the tab list does not jump. + titlebar.style.marginTop = `${ + navbar.getBoundingClientRect().bottom - + document.getElementById("browser").getBoundingClientRect().y + }px`; + return { + start(width, options) { + if (rightSide) { + // Temporarily use idle CSS to measure the controls' final positions. + sidebar.removeAttribute("animate"); + document.documentElement.removeAttribute("zen-compact-animating"); + const destination = controls.map(element => + element.getBoundingClientRect() + ); + const idleInset = getComputedStyle(navbar).marginLeft; + document.documentElement.setAttribute( + "zen-compact-animating", + "true" + ); + sidebar.setAttribute("animate", "true"); + const animatingInset = getComputedStyle(navbar).marginLeft; + const headerWidth = sidebarExpanded + ? header.getBoundingClientRect().width + : width; + const layout = [ + { + element: navbar, + property: "marginLeft", + values: [animatingInset, idleInset], + }, + { + element: navbar, + property: "marginRight", + values: [sidebarExpanded ? "0px" : `-${width}px`, "0px"], + }, + { + element: header, + property: "marginLeft", + values: [sidebarExpanded ? `-${headerWidth}px` : "0px", "0px"], + }, + ]; + if (!hiding) { + layout.forEach(({ values }) => values.reverse()); + } + const applyLayout = index => + layout.forEach(({ element, property, values }) => { + element.style[property] = values[index]; + }); + applyLayout(1); + const shifted = controls.map(element => + element.getBoundingClientRect() + ); + applyLayout(0); + if (!hiding) { + controls.forEach((element, index) => { + if (index === 0 || sidebarExpanded) { + destination[index] = DOMRect.fromRect(shifted[index]); + } + }); + if (sidebarExpanded) { + // The expanded header finishes in the sidebar, not the navbar. + destination[1].x = + sidebar.getBoundingClientRect().x + + parseFloat(getComputedStyle(sidebar).paddingLeft) - + width; + } + } + const sidebarDisplacement = hiding ? -width : width; + controls.forEach((element, index) => { + const current = element.getBoundingClientRect(); + const x = bounds[index].x - current.x; + const y = bounds[index].y - current.y; + const from = `translate(${x}px, ${y}px)`; + const displacement = index === 1 ? sidebarDisplacement : 0; + const destinationX = + destination[index].x - shifted[index].x + displacement; + const destinationY = destination[index].y - shifted[index].y; + const to = `translate(${destinationX}px, ${destinationY}px)`; + element.style.transform = from; + animations.push( + gZenUIManager.motion.animate( + element, + { transform: [from, to] }, + options + ) + ); + }); + for (const { element, property, values } of layout) { + animations.push( + gZenUIManager.motion.animate( + element, + { [property]: values }, + options + ) + ); + } + return; + } + const leadingSpace = + navbarContents.getBoundingClientRect().x - + navbar.getBoundingClientRect().x; + const compactMargin = + bounds[1].x + + controls[1].getBoundingClientRect().width - + navbarContents.getBoundingClientRect().x + + (hiding ? width : 0); + const margins = hiding + ? [ + initialNavbarX - navbarContents.getBoundingClientRect().x, + compactMargin, + ] + : [ + compactMargin, + sidebarExpanded ? -leadingSpace : compactMargin - width, + ]; + navbarContents.style.marginLeft = `${margins[0]}px`; + animations.push( + gZenUIManager.motion.animate( + navbarContents, + { marginLeft: margins }, + options + ) + ); + controls.forEach((element, index) => { + const current = element.getBoundingClientRect(); + const x = bounds[index].x - current.x; + const y = bounds[index].y - current.y; + const from = `translate(${x}px, ${y}px)`; + const to = `translate(${x + (hiding ? width : -width)}px, ${y}px)`; + element.style.transform = from; + animations.push( + gZenUIManager.motion.animate( + element, + { transform: [from, to] }, + options + ) + ); + }); + }, + restore() { + animations.forEach(animation => animation.cancel()); + for (const { element, property, value, priority } of styles) { + if (value) { + element.style.setProperty(property, value, priority); + } else { + element.style.removeProperty(property); + } + } + }, + }; + }, + async _updateEvent() { const value = !this.preference; + const toolbarTransition = this._prepareToolbarTransition(); const setAttrs = () => { // We use this element in order to make it persis across restarts, by using the XULStore. // main-window can't store attributes other than window sizes, so we use this instead @@ -386,17 +589,21 @@ window.gZenCompactModeManager = { // IF we are animating IN, call the callbacks first so we can calculate the width // once the window buttons are shown gZenWorkspaces._processingResize = true; - if (!this.preference) { - setAttrs(); - this._dispatchLayoutUpdate(); - await this.animateCompactMode(value); - } else { - await this.animateCompactMode(value); - setAttrs(); - this._dispatchLayoutUpdate(); + try { + if (!this.preference) { + setAttrs(); + this._dispatchLayoutUpdate(); + await this.animateCompactMode(value, toolbarTransition); + } else { + await this.animateCompactMode(value, toolbarTransition); + setAttrs(); + this._dispatchLayoutUpdate(); + } + } finally { + toolbarTransition?.restore(); + gZenWorkspaces._processingResize = false; } this.updateContextMenu(); - gZenWorkspaces._processingResize = false; if (isUrlbarFocused) { gURLBar.focus(); } @@ -424,19 +631,13 @@ window.gZenCompactModeManager = { ); }, - animateCompactMode(isCompactMode) { + animateCompactMode(isCompactMode, toolbarTransition = null) { return new Promise(resolve => { document.documentElement.setAttribute("zen-compact-animating", "true"); const canHideSidebar = this.canHideSidebar; - let canAnimate = - lazy.COMPACT_MODE_CAN_ANIMATE_SIDEBAR && - !this.isSidebarPotentiallyOpen() && - !this._preventAnimateCollapse; + const canAnimate = this._canAnimateSidebar; delete this._preventAnimateCollapse; - if (typeof this._wasInCompactMode !== "undefined") { - canAnimate = false; - delete this._wasInCompactMode; - } + delete this._wasInCompactMode; // Do this so we can get the correct width ONCE compact mode styled have been applied if (canAnimate) { this.sidebar.setAttribute("animate", "true"); @@ -449,9 +650,10 @@ window.gZenCompactModeManager = { this.sidebar.style.removeProperty("margin-right"); this.sidebar.style.removeProperty("margin-left"); this.sidebar.style.removeProperty("transform"); - let sidebarWidth = window.windowUtils.getBoundsWithoutFlushing( - this.sidebar - ).width; + // Reparenting controls invalidates layout; compensation needs fresh bounds. + let sidebarWidth = toolbarTransition + ? this.sidebar.getBoundingClientRect().width + : window.windowUtils.getBoundsWithoutFlushing(this.sidebar).width; const elementSeparation = ZenThemeModifier.elementSeparation; if (!canAnimate) { this.sidebar.removeAttribute("animate"); @@ -465,8 +667,16 @@ window.gZenCompactModeManager = { resolve(); return; } - sidebarWidth -= elementSeparation * (isCompactMode ? 1 : 3); + sidebarWidth -= + elementSeparation * (toolbarTransition || isCompactMode ? 1 : 3); + const animationOptions = { + ease: isCompactMode ? "easeIn" : "easeOut", + type: "spring", + bounce: 0, + duration: 0.1, + }; if (canHideSidebar && isCompactMode) { + toolbarTransition?.start(sidebarWidth, animationOptions); this._setElementExpandAttribute(this.sidebar, false); gZenUIManager.motion .animate( @@ -478,12 +688,7 @@ window.gZenCompactModeManager = { ], marginLeft: [0, this.sidebarIsOnRight ? 0 : `-${sidebarWidth}px`], }, - { - ease: "easeIn", - type: "spring", - bounce: 0, - duration: 0.1, - } + animationOptions ) .then(() => { this.sidebar.style.transition = "none"; @@ -517,21 +722,19 @@ window.gZenCompactModeManager = { } else { this.sidebar.style.marginLeft = `-${sidebarWidth}px`; } + toolbarTransition?.start(sidebarWidth, animationOptions); gZenUIManager.motion .animate( this.sidebar, this.sidebarIsOnRight ? { marginRight: [`-${sidebarWidth}px`, 0], - transform: ["translateX(100%)", "translateX(0)"], + ...(!toolbarTransition && { + transform: ["translateX(100%)", "translateX(0)"], + }), } : { marginLeft: [`-${sidebarWidth}px`, 0] }, - { - ease: "easeOut", - type: "spring", - bounce: 0, - duration: 0.1, - } + animationOptions ) .then(() => { this.sidebar.removeAttribute("animate"); diff --git a/src/zen/compact-mode/sidebar.inc.css b/src/zen/compact-mode/sidebar.inc.css index b5ca89e27ad..04da3177fc4 100644 --- a/src/zen/compact-mode/sidebar.inc.css +++ b/src/zen/compact-mode/sidebar.inc.css @@ -41,6 +41,24 @@ ) !important; } + @media (-moz-platform: macos) and (not -moz-pref('zen.view.compact.hide-toolbar')) { + &:dir(ltr):not([zen-single-toolbar='true'])[zen-right-side='true'] #nav-bar #zen-sidebar-top-buttons-customization-target { + padding-inline-start: 0 !important; + } + + &:dir(ltr):not([zen-single-toolbar='true']):not([zen-right-side='true']) #nav-bar { + & > .titlebar-buttonbox-container { + margin-inline-start: calc( + var(--zen-toolbox-padding) - (var(--zen-element-separation) - 3px) + ); + } + + & #zen-sidebar-top-buttons-customization-target { + padding-inline-start: 0 !important; + } + } + } + &[zen-window-buttons-reversed='true'] #zen-appcontent-navbar-wrapper #nav-bar { margin-right: var(--zen-element-separation) !important; margin-left: calc(var(--zen-element-separation) - 3px) !important; diff --git a/src/zen/tests/compact_mode/browser.toml b/src/zen/tests/compact_mode/browser.toml index ad811e17bda..e2f2113226e 100644 --- a/src/zen/tests/compact_mode/browser.toml +++ b/src/zen/tests/compact_mode/browser.toml @@ -4,6 +4,9 @@ [DEFAULT] +["browser_compact_mode_controls.js"] +skip-if = ["os != 'mac'"] + ["browser_compact_mode_layout_event.js"] ["browser_compact_mode_width.js"] diff --git a/src/zen/tests/compact_mode/browser_compact_mode_controls.js b/src/zen/tests/compact_mode/browser_compact_mode_controls.js new file mode 100644 index 00000000000..8f3a80ce08f --- /dev/null +++ b/src/zen/tests/compact_mode/browser_compact_mode_controls.js @@ -0,0 +1,368 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +async function checkToolbarControls({ + expanded = true, + spacing = 8, + animate = true, + rightSide = false, +} = {}) { + await SpecialPowers.pushPrefEnv({ + set: [ + ["zen.view.use-single-toolbar", false], + ["zen.view.sidebar-expanded", expanded], + ["zen.theme.content-element-separation", spacing], + ["zen.tabs.vertical.right-side", rightSide], + ["zen.view.compact.enable-at-startup", false], + ["zen.view.compact.hide-tabbar", true], + ["zen.view.compact.hide-toolbar", false], + ["zen.view.compact.animate-sidebar", animate], + ], + }); + + await BrowserTestUtils.withNewTab("about:robots", async () => { + const manager = gZenCompactModeManager; + if (manager.preference) { + const toggled = BrowserTestUtils.waitForEvent( + window, + "ZenCompactMode:Toggled" + ); + manager.toggle(true); + await toggled; + } + const controls = [ + gZenVerticalTabsManager.actualWindowButtons, + document.getElementById("zen-sidebar-top-buttons-customization-target"), + ]; + const layoutElements = [ + document.getElementById("titlebar"), + document.getElementById("nav-bar-customization-target"), + document.getElementById("nav-bar"), + document.getElementById("zen-sidebar-top-buttons"), + ]; + const navigation = ["back-button", "forward-button", "reload-button"].map( + id => document.getElementById(id) + ); + const toolbarElements = [ + ...navigation, + document.getElementById("urlbar-container"), + document.getElementById("PanelUI-button"), + ]; + const toolbarBounds = () => + toolbarElements.map(element => element.getBoundingClientRect()); + const compactButton = document.getElementById("zen-toggle-compact-mode"); + const customizations = [...controls[1].children]; + const assertOriginalButtonOrder = direction => { + if (!rightSide) { + return; + } + const menu = document + .getElementById("PanelUI-button") + .getBoundingClientRect(); + const button = compactButton.getBoundingClientRect(); + const toggleInNavbar = manager.preference || !expanded; + Assert.ok( + toggleInNavbar ? button.right <= menu.x : menu.right <= button.x, + `${direction} preserves the original ${toggleInNavbar ? "toggle then menu" : "menu then toggle"} order` + ); + }; + + for (const direction of ["hide", "show", "hide", "show"]) { + const initial = controls.map(element => ({ + bounds: element.getBoundingClientRect(), + style: element.style.cssText, + })); + const layoutStyles = layoutElements.map(element => element.style.cssText); + const samples = []; + const initialToolbar = toolbarBounds(); + const initialButtonX = compactButton.getBoundingClientRect().x; + const initialTabY = gBrowser.selectedTab.getBoundingClientRect().y; + const initialNavigation = navigation.map( + element => element.getBoundingClientRect().x + ); + let finished = false; + const sampling = new Promise(resolve => { + function sample() { + samples.push({ + controls: controls.map(element => element.getBoundingClientRect()), + animating: manager.sidebar.hasAttribute("animate"), + buttonX: compactButton.getBoundingClientRect().x, + toolbar: toolbarBounds(), + tabY: gBrowser.selectedTab.getBoundingClientRect().y, + navigation: navigation.map( + element => element.getBoundingClientRect().x + ), + }); + if (finished) { + resolve(); + } else { + requestAnimationFrame(sample); + } + } + requestAnimationFrame(sample); + }); + const toggled = BrowserTestUtils.waitForEvent( + window, + "ZenCompactMode:Toggled" + ); + let beforeCleanupButtonX; + let beforeCleanupToolbar; + const motion = gZenUIManager.motion; + const animateElement = motion.animate; + motion.animate = function (element, keyframes, options) { + const animation = animateElement(element, keyframes, options); + if (element !== manager.sidebar) { + return animation; + } + return { + then(onFulfilled, onRejected) { + return animation.then(value => { + beforeCleanupButtonX = compactButton.getBoundingClientRect().x; + beforeCleanupToolbar = toolbarBounds(); + return onFulfilled(value); + }, onRejected); + }, + }; + }; + try { + manager.toggle(true); + await toggled; + } finally { + motion.animate = animateElement; + } + finished = true; + await sampling; + + if (animate) { + const animationSamples = samples.filter(sample => sample.animating); + Assert.greater( + animationSamples.length, + 0, + `${direction} samples an active animation` + ); + const expectedTabY = + direction === "hide" + ? initialTabY + : gBrowser.selectedTab.getBoundingClientRect().y; + Assert.lessOrEqual( + Math.max( + ...animationSamples.map(sample => + Math.abs(sample.tabY - expectedTabY) + ) + ), + 0.5, + `${direction} keeps sidebar content at the same height` + ); + } + assertOriginalButtonOrder(direction); + if (animate && rightSide) { + toolbarBounds().forEach((bounds, index) => { + for (const edge of ["x", "right"]) { + const positions = samples.map( + sample => sample.toolbar[index][edge] + ); + Assert.ok( + Math.min(...positions) >= + Math.min(initialToolbar[index][edge], bounds[edge]) - 1 && + Math.max(...positions) <= + Math.max(initialToolbar[index][edge], bounds[edge]) + 1, + `${direction} keeps ${toolbarElements[index].id} ${edge} within its endpoints` + ); + Assert.lessOrEqual( + Math.abs(beforeCleanupToolbar[index][edge] - bounds[edge]), + 1, + `${direction} reaches ${toolbarElements[index].id} ${edge} before cleanup` + ); + } + }); + } + const finalButtonX = compactButton.getBoundingClientRect().x; + if (animate && rightSide) { + Assert.lessOrEqual( + Math.abs(beforeCleanupButtonX - finalButtonX), + 4, + `${direction} reaches the compact-mode button's destination before cleanup` + ); + } + Assert.greaterOrEqual( + Math.min(...samples.map(sample => sample.buttonX)), + Math.min(initialButtonX, finalButtonX) - 0.5, + `${direction} keeps the compact-mode button from overshooting left` + ); + Assert.lessOrEqual( + Math.max(...samples.map(sample => sample.buttonX)), + Math.max(initialButtonX, finalButtonX) + 0.5, + `${direction} keeps the compact-mode button from overshooting right` + ); + if (!rightSide) { + Assert.lessOrEqual( + Math.abs(finalButtonX - initialButtonX), + 0.5, + `${direction} keeps the compact-mode button in place` + ); + } + navigation.forEach((element, index) => { + const finalX = element.getBoundingClientRect().x; + const positions = samples.map(sample => sample.navigation[index]); + Assert.greaterOrEqual( + Math.min(...positions), + Math.min(initialNavigation[index], finalX) - 4, + `${direction} keeps ${element.id} from overshooting left` + ); + Assert.lessOrEqual( + Math.max(...positions), + Math.max(initialNavigation[index], finalX) + 4, + `${direction} keeps ${element.id} from overshooting right` + ); + }); + for (const element of customizations) { + Assert.equal( + element.parentElement, + controls[1], + `${direction} preserves toolbar items` + ); + } + for (let index = 0; index < controls.length; index++) { + const finalX = controls[index].getBoundingClientRect().x; + const positions = samples.map(sample => sample.controls[index].x); + if (!rightSide) { + Assert.lessOrEqual( + Math.max( + ...positions.map(x => Math.abs(x - initial[index].bounds.x)) + ), + 4, + `${direction} keeps control ${index} in place` + ); + } + Assert.greaterOrEqual( + Math.min(...positions), + Math.min(initial[index].bounds.x, finalX) - 4, + `${direction} keeps control ${index} from overshooting left` + ); + Assert.lessOrEqual( + Math.max(...positions), + Math.max(initial[index].bounds.x, finalX) + 4, + `${direction} keeps control ${index} from overshooting right` + ); + Assert.lessOrEqual( + Math.max( + ...samples.map(sample => + Math.abs(sample.controls[index].y - initial[index].bounds.y) + ) + ), + 0.5, + `${direction} keeps control ${index} at the same height` + ); + Assert.equal( + controls[index].style.cssText, + initial[index].style, + `${direction} restores the control's original style` + ); + } + layoutElements.forEach((element, index) => + Assert.equal( + element.style.cssText, + layoutStyles[index], + `${direction} restores ${element.id} styles` + ) + ); + } + }); + await SpecialPowers.popPrefEnv(); +} + +add_task(async function test_toolbar_controls_stay_in_place() { + await checkToolbarControls(); +}); + +add_task(async function test_collapsed_sidebar_controls() { + await checkToolbarControls({ expanded: false }); +}); + +add_task(async function test_right_sidebar_controls() { + await checkToolbarControls({ rightSide: true }); +}); + +add_task(async function test_collapsed_right_sidebar_controls() { + await checkToolbarControls({ rightSide: true, expanded: false }); +}); + +add_task(async function test_custom_browser_spacing() { + for (const rightSide of [false, true]) { + for (const spacing of [0, 12]) { + await checkToolbarControls({ spacing, rightSide }); + } + } +}); + +add_task(async function test_disabled_animation() { + for (const rightSide of [false, true]) { + await checkToolbarControls({ animate: false, rightSide }); + } +}); + +add_task(async function test_rtl_does_not_compensate_toolbar() { + await SpecialPowers.pushPrefEnv({ + set: [ + ["zen.view.use-single-toolbar", false], + ["zen.view.sidebar-expanded", true], + ["zen.tabs.vertical.right-side", false], + ["zen.view.compact.animate-sidebar", true], + ["zen.view.compact.enable-at-startup", false], + ], + }); + const root = document.documentElement; + const originalDirection = root.getAttribute("dir"); + root.setAttribute("dir", "rtl"); + try { + await BrowserTestUtils.withNewTab("about:robots", async () => { + const manager = gZenCompactModeManager; + const elements = [ + [gZenVerticalTabsManager.actualWindowButtons, "transform"], + [ + document.getElementById( + "zen-sidebar-top-buttons-customization-target" + ), + "transform", + ], + [document.getElementById("titlebar"), "margin-top"], + [ + document.getElementById("nav-bar-customization-target"), + "margin-left", + ], + ]; + for (const direction of ["hide", "show"]) { + const before = elements.map(([element, property]) => + element.style.getPropertyValue(property) + ); + const toggled = BrowserTestUtils.waitForEvent( + window, + "ZenCompactMode:Toggled" + ); + manager.toggle(true); + await new Promise(resolve => requestAnimationFrame(resolve)); + Assert.ok( + manager.sidebar.hasAttribute("animate"), + "Samples the RTL animation" + ); + elements.forEach(([element, property], index) => + Assert.equal( + element.style.getPropertyValue(property), + before[index], + `${direction} preserves RTL ${property}` + ) + ); + await toggled; + } + }); + } finally { + if (originalDirection === null) { + root.removeAttribute("dir"); + } else { + root.setAttribute("dir", originalDirection); + } + await SpecialPowers.popPrefEnv(); + } +});