diff --git a/build/flatpak/app.zen_browser.zen.yml.template b/build/flatpak/app.zen_browser.zen.yml.template index d18329c46..821b0f1e6 100644 --- a/build/flatpak/app.zen_browser.zen.yml.template +++ b/build/flatpak/app.zen_browser.zen.yml.template @@ -7,7 +7,7 @@ base-version: '24.08' add-extensions: org.freedesktop.Platform.ffmpeg-full: directory: lib/ffmpeg - version: '24.08' + version: '24.08.26' add-ld-path: . app.zen_browser.zen.systemconfig: directory: etc/zen diff --git a/locales/en-US/browser/browser/preferences/zen-preferences.ftl b/locales/en-US/browser/browser/preferences/zen-preferences.ftl index 5ab322e34..1c2d18229 100644 --- a/locales/en-US/browser/browser/preferences/zen-preferences.ftl +++ b/locales/en-US/browser/browser/preferences/zen-preferences.ftl @@ -33,8 +33,6 @@ zen-glance-trigger-shift-click = .label = Shift + Click zen-glance-trigger-meta-click = .label = Meta (Command) + Click -zen-glance-trigger-mantain-click = - .label = Hold Click (Coming Soon!) zen-look-and-feel-compact-view-header = Show in compact view zen-look-and-feel-compact-view-description = Only show the toolbars you use! diff --git a/prefs/glance.yaml b/prefs/glance.yaml index cae4738d2..49125fe30 100644 --- a/prefs/glance.yaml +++ b/prefs/glance.yaml @@ -8,9 +8,6 @@ - name: zen.glance.enable-contextmenu-search value: true -- name: zen.glance.hold-duration - value: 300 # in ms - - name: zen.glance.open-essential-external-links value: true diff --git a/scripts/run_tests.py b/scripts/run_tests.py index a4adecd7c..90a7995a8 100644 --- a/scripts/run_tests.py +++ b/scripts/run_tests.py @@ -6,6 +6,7 @@ import os import sys import json from pathlib import Path +from typing import Any IGNORE_PREFS_FILE_IN = os.path.join( 'src', 'zen', 'tests', 'ignorePrefs.json' @@ -15,6 +16,15 @@ IGNORE_PREFS_FILE_OUT = os.path.join( ) +class JSONWithCommentsDecoder(json.JSONDecoder): + def __init__(self, **kw): + super().__init__(**kw) + + def decode(self, s: str) -> Any: + s = '\n'.join(l for l in s.split('\n') if not l.lstrip(' ').startswith('//')) + return super().decode(s) + + def copy_ignore_prefs(): print("Copying ignorePrefs.json from src/zen/tests to engine/testing/mochitest...") # if there are prefs that dont exist on output file, copy them from input file @@ -22,7 +32,7 @@ def copy_ignore_prefs(): with open(IGNORE_PREFS_FILE_OUT, 'r') as f: all_prefs = json.load(f) with open(IGNORE_PREFS_FILE_IN, 'r') as f_in: - new_prefs = json.load(f_in) + new_prefs = json.load(f_in, cls=JSONWithCommentsDecoder) all_prefs.extend(p for p in new_prefs if p not in all_prefs) with open(IGNORE_PREFS_FILE_OUT, 'w') as f_out: json.dump(all_prefs, f_out, indent=2) diff --git a/src/browser/components/preferences/zenLooksAndFeel.inc.xhtml b/src/browser/components/preferences/zenLooksAndFeel.inc.xhtml index 259fff79e..068db06b6 100644 --- a/src/browser/components/preferences/zenLooksAndFeel.inc.xhtml +++ b/src/browser/components/preferences/zenLooksAndFeel.inc.xhtml @@ -87,7 +87,6 @@ #ifdef XP_MACOSX #endif - diff --git a/src/zen/common/styles/zen-browser-container.css b/src/zen/common/styles/zen-browser-container.css index 62045e7b0..ea34f8434 100644 --- a/src/zen/common/styles/zen-browser-container.css +++ b/src/zen/common/styles/zen-browser-container.css @@ -8,12 +8,12 @@ #tabbrowser-tabpanels[dragging-split='true'] { width: -moz-available; position: relative; - overflow: clip; &.browserSidebarContainer { - :root:not([zen-no-padding='true']) & { + :root:not([zen-no-padding='true']) &:not(.zen-glance-overlay) { border-radius: var(--zen-native-inner-radius); box-shadow: var(--zen-big-shadow); + overflow: clip; } & browser[type='content'] { diff --git a/src/zen/glance/ZenGlanceManager.mjs b/src/zen/glance/ZenGlanceManager.mjs index bccffa314..de653c244 100644 --- a/src/zen/glance/ZenGlanceManager.mjs +++ b/src/zen/glance/ZenGlanceManager.mjs @@ -1,312 +1,788 @@ // This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. + { + /** + * Manages the Zen Glance feature - a preview overlay system for tabs + * Allows users to preview content without fully opening new tabs + */ class nsZenGlanceManager extends nsZenDOMOperatedFeature { + // Animation state _animating = false; _lazyPref = {}; + // Glance management #glances = new Map(); #currentGlanceID = null; - #confirmationTimeout = null; + // Animation flags + animatingOpen = false; + animatingFullOpen = false; + closingGlance = false; + #duringOpening = false; + #ignoreClose = false; + + // Arc animation configuration + #ARC_CONFIG = Object.freeze({ + ARC_STEPS: 40, // Increased for smoother bounce + MAX_ARC_HEIGHT: 30, + ARC_HEIGHT_RATIO: 0.2, // Arc height = distance * ratio (capped at MAX_ARC_HEIGHT) + }); + init() { + this.#setupEventListeners(); + this.#setupPreferences(); + this.#setupObservers(); + } + + #setupEventListeners() { window.addEventListener('TabClose', this.onTabClose.bind(this)); window.addEventListener('TabSelect', this.onLocationChange.bind(this)); + document + .getElementById('tabbrowser-tabpanels') + .addEventListener('click', this.onOverlayClick.bind(this)); + } + + #setupPreferences() { XPCOMUtils.defineLazyPreferenceGetter( this._lazyPref, 'SHOULD_OPEN_EXTERNAL_TABS_IN_GLANCE', 'zen.glance.open-essential-external-links', false ); + } - document - .getElementById('tabbrowser-tabpanels') - .addEventListener('click', this.onOverlayClick.bind(this)); + #setupObservers() { Services.obs.addObserver(this, 'quit-application-requested'); } + /** + * Handle main command set events for glance operations + * @param {Event} event - The command event + */ handleMainCommandSet(event) { const command = event.target; - switch (command.id) { - case 'cmd_zenGlanceClose': - this.closeGlance({ onTabClose: true }); - break; - case 'cmd_zenGlanceExpand': - this.fullyOpenGlance(); - break; - case 'cmd_zenGlanceSplit': - this.splitGlance(); - break; + const commandHandlers = { + cmd_zenGlanceClose: () => this.closeGlance({ onTabClose: true }), + cmd_zenGlanceExpand: () => this.fullyOpenGlance(), + cmd_zenGlanceSplit: () => this.splitGlance(), + }; + + const handler = commandHandlers[command.id]; + if (handler) { + handler(); } } + /** + * Get the current glance browser element + * @returns {Browser} The current browser or null + */ get #currentBrowser() { return this.#glances.get(this.#currentGlanceID)?.browser; } + /** + * Get the current glance tab element + * @returns {Tab} The current tab or null + */ get #currentTab() { return this.#glances.get(this.#currentGlanceID)?.tab; } + /** + * Get the current glance parent tab element + * @returns {Tab} The parent tab or null + */ get #currentParentTab() { return this.#glances.get(this.#currentGlanceID)?.parentTab; } + /** + * Handle clicks on the glance overlay + * @param {Event} event - The click event + */ onOverlayClick(event) { - if (event.target === this.overlay && event.originalTarget !== this.contentWrapper) { + const isOverlayClick = event.target === this.overlay; + const isNotContentClick = event.originalTarget !== this.contentWrapper; + + if (isOverlayClick && isNotContentClick) { this.closeGlance({ onTabClose: true }); } } + /** + * Handle application observer notifications + * @param {Object} subject - The subject of the notification + * @param {string} topic - The topic of the notification + */ observe(subject, topic) { - switch (topic) { - case 'quit-application-requested': - this.onUnload(); - break; + if (topic === 'quit-application-requested') { + this.onUnload(); } } + /** + * Clean up all glances when the application is unloading + */ onUnload() { - // clear everything - /* eslint-disable no-unused-vars */ - for (let [id, glance] of this.#glances) { + for (const [, glance] of this.#glances) { gBrowser.removeTab(glance.tab, { animate: false }); } + this.#glances.clear(); } + /** + * Create a new browser element for a glance + * @param {string} url - The URL to load + * @param {Tab} currentTab - The current tab + * @param {Tab} existingTab - Optional existing tab to reuse + * @returns {Browser} The created browser element + */ createBrowserElement(url, currentTab, existingTab = null) { - const newTabOptions = { + const newTabOptions = this.#createTabOptions(currentTab); + const newUUID = gZenUIManager.generateUuidv4(); + + currentTab._selected = true; + const newTab = + existingTab ?? gBrowser.addTrustedTab(Services.io.newURI(url).spec, newTabOptions); + + this.#configureNewTab(newTab, currentTab, newUUID); + this.#registerGlance(newTab, currentTab, newUUID); + + gBrowser.selectedTab = newTab; + return this.#currentBrowser; + } + + /** + * Create tab options for a new glance tab + * @param {Tab} currentTab - The current tab + * @returns {Object} Tab options + */ + #createTabOptions(currentTab) { + return { userContextId: currentTab.getAttribute('usercontextid') || '', skipBackgroundNotify: true, insertTab: true, skipLoad: false, }; - currentTab._selected = true; - const newUUID = gZenUIManager.generateUuidv4(); - const newTab = - existingTab ?? gBrowser.addTrustedTab(Services.io.newURI(url).spec, newTabOptions); + } + + /** + * Configure a new tab for glance usage + * @param {Tab} newTab - The new tab to configure + * @param {Tab} currentTab - The current tab + * @param {string} glanceId - The glance ID + */ + #configureNewTab(newTab, currentTab, glanceId) { if (currentTab.hasAttribute('zenDefaultUserContextId')) { newTab.setAttribute('zenDefaultUserContextId', true); } + currentTab.querySelector('.tab-content').appendChild(newTab); newTab.setAttribute('zen-glance-tab', true); - newTab.setAttribute('glance-id', newUUID); - currentTab.setAttribute('glance-id', newUUID); - this.#glances.set(newUUID, { + newTab.setAttribute('glance-id', glanceId); + currentTab.setAttribute('glance-id', glanceId); + } + + /** + * Register a new glance in the glances map + * @param {Tab} newTab - The new tab + * @param {Tab} currentTab - The current tab + * @param {string} glanceId - The glance ID + */ + #registerGlance(newTab, currentTab, glanceId) { + this.#glances.set(glanceId, { tab: newTab, parentTab: currentTab, browser: newTab.linkedBrowser, }); - this.#currentGlanceID = newUUID; - gBrowser.selectedTab = newTab; - return this.#currentBrowser; + this.#currentGlanceID = glanceId; } + /** + * Fill overlay references from a browser element + * @param {Browser} browser - The browser element + */ fillOverlay(browser) { this.overlay = browser.closest('.browserSidebarContainer'); this.browserWrapper = browser.closest('.browserContainer'); this.contentWrapper = browser.closest('.browserStack'); } + /** + * Create new overlay buttons with animation + * @returns {DocumentFragment} The cloned button template + */ #createNewOverlayButtons() { - const newButtons = document - .getElementById('zen-glance-sidebar-template') - .content.cloneNode(true); + const template = document.getElementById('zen-glance-sidebar-template'); + const newButtons = template.content.cloneNode(true); const container = newButtons.querySelector('.zen-glance-sidebar-container'); + + this.#animateOverlayButtons(container); + return newButtons; + } + + /** + * Animate the overlay buttons entrance + * @param {Element} container - The button container + */ + #animateOverlayButtons(container) { container.style.opacity = 0; + + const xOffset = gZenVerticalTabsManager._prefsRightSide ? 20 : -20; + gZenUIManager.motion.animate( container, { opacity: [0, 1], + x: [xOffset, 0], }, { - duration: 0.2, + duration: 0.3, type: 'spring', - delay: 0.05, + delay: 0.15, + bounce: 0, } ); - return newButtons; } + /** + * Open a glance overlay with the specified data + * @param {Object} data - Glance data including URL, position, and dimensions + * @param {Tab} existingTab - Optional existing tab to reuse + * @param {Tab} ownerTab - The tab that owns this glance + * @returns {Promise} Promise that resolves to the glance tab + */ openGlance(data, existingTab = null, ownerTab = null) { if (this.#currentBrowser) { return; } + if (gBrowser.selectedTab === this.#currentParentTab) { gBrowser.selectedTab = this.#currentTab; return; } - this.animatingOpen = true; - this._animating = true; - - const initialX = data.clientX; - const initialY = data.clientY; - const initialWidth = data.width; - const initialHeight = data.height; - - this.browserWrapper?.removeAttribute('animate'); - this.browserWrapper?.removeAttribute('has-finished-animation'); - this.overlay?.removeAttribute('post-fade-out'); + this.#setAnimationState(true); const currentTab = ownerTab ?? gBrowser.selectedTab; - const browserElement = this.createBrowserElement(data.url, currentTab, existingTab); this.fillOverlay(browserElement); - this.overlay.classList.add('zen-glance-overlay'); + return this.#animateGlanceOpening(data, browserElement); + } + + /** + * Set animation state flags + * @param {boolean} isAnimating - Whether animations are active + */ + #setAnimationState(isAnimating) { + this.animatingOpen = isAnimating; + this._animating = isAnimating; + } + + /** + * Animate the glance opening process + * @param {Object} data - Glance data + * @param {Browser} browserElement - The browser element + * @returns {Promise} Promise that resolves to the glance tab + */ + #animateGlanceOpening(data, browserElement) { return new Promise((resolve) => { window.requestAnimationFrame(() => { - this.quickOpenGlance(); - const newButtons = this.#createNewOverlayButtons(); - this.browserWrapper.appendChild(newButtons); - - // Performance: backdrop-filter blur on Windows significantly impacts scroll smoothness - // in the Glance preview (particularly for wheel scrolling). Avoid applying it on Windows. - const parentSidebarContainer = this.#currentParentTab.linkedBrowser.closest( - '.browserSidebarContainer' - ); - gZenUIManager.motion.animate( - parentSidebarContainer, - { - scale: [1, 0.98], - opacity: [1, 0.6], - }, - { - duration: 0.4, - type: 'spring', - bounce: 0.2, - } - ); - this.overlay.removeAttribute('fade-out'); - this.browserWrapper.setAttribute('animate', true); - const top = initialY + initialHeight / 2; - const left = initialX + initialWidth / 2; - this.browserWrapper.style.top = `${top}px`; - this.browserWrapper.style.left = `${left}px`; - this.browserWrapper.style.width = `${initialWidth}px`; - this.browserWrapper.style.height = `${initialHeight}px`; - this.browserWrapper.style.opacity = 0.8; - this.#glances.get(this.#currentGlanceID).originalPosition = { - top: this.browserWrapper.style.top, - left: this.browserWrapper.style.left, - width: this.browserWrapper.style.width, - height: this.browserWrapper.style.height, - }; - this.browserWrapper.style.transform = 'translate(-50%, -50%)'; - this.overlay.style.overflow = 'visible'; - gZenUIManager.motion - .animate( - this.browserWrapper, - { - top: '50%', - left: '50%', - width: '85%', - height: '100%', - opacity: 1, - }, - { - duration: 0.3, - type: 'spring', - bounce: 0.2, - } - ) - .then(() => { - gBrowser.tabContainer._invalidateCachedTabs(); - this.overlay.style.removeProperty('overflow'); - this.browserWrapper.removeAttribute('animate'); - this.browserWrapper.setAttribute('has-finished-animation', true); - this._animating = false; - this.animatingOpen = false; - this.#currentTab.dispatchEvent(new Event('GlanceOpen', { bubbles: true })); - resolve(this.#currentTab); - }); + this.#prepareGlanceAnimation(data, browserElement); + this.#executeGlanceAnimation(data, browserElement, resolve); }); }); } - _clearContainerStyles(container) { + /** + * Prepare the glance for animation + * @param {Object} data - Glance data + * @param {Browser} browserElement - The browser element + */ + #prepareGlanceAnimation(data, browserElement) { + this.quickOpenGlance(); + const newButtons = this.#createNewOverlayButtons(); + this.browserWrapper.appendChild(newButtons); + + this.#animateParentBackground(); + this.#setupGlancePositioning(data); + this.#configureBrowserElement(browserElement); + } + + /** + * Animate the parent background + */ + #animateParentBackground() { + const parentSidebarContainer = this.#currentParentTab.linkedBrowser.closest( + '.browserSidebarContainer' + ); + + gZenUIManager.motion.animate( + parentSidebarContainer, + { + scale: [1, 0.98], + opacity: [1, 0.6], + }, + { + duration: 0.3, + type: 'spring', + bounce: 0.2, + } + ); + } + + /** + * Set up glance positioning + * @param {Object} data - Glance data with position and dimensions + */ + #setupGlancePositioning(data) { + const { clientX, clientY, width, height } = data; + const top = clientY + height / 2; + const left = clientX + width / 2; + + this.overlay.removeAttribute('fade-out'); + this.browserWrapper.setAttribute('animate', true); + this.browserWrapper.style.top = `${top}px`; + this.browserWrapper.style.left = `${left}px`; + this.browserWrapper.style.width = `${width}px`; + this.browserWrapper.style.height = `${height}px`; + + this.#storeOriginalPosition(); + this.overlay.style.overflow = 'visible'; + } + + /** + * Store the original position for later restoration + */ + #storeOriginalPosition() { + this.#glances.get(this.#currentGlanceID).originalPosition = { + top: this.browserWrapper.style.top, + left: this.browserWrapper.style.left, + width: this.browserWrapper.style.width, + height: this.browserWrapper.style.height, + }; + } + + /** + * Handle element preview if provided + * @param {Object} data - Glance data + * @returns {Element|null} The preview element or null + */ + #handleElementPreview(data) { + if (!data.elementData) { + return null; + } + + const imageDataElement = document.createXULElement('image'); + imageDataElement.setAttribute('src', data.elementData); + imageDataElement.classList.add('zen-glance-element-preview'); + this.browserWrapper.prepend(imageDataElement); + this.#glances.get(this.#currentGlanceID).elementImageData = data.elementData; + + return imageDataElement; + } + + /** + * Configure browser element for animation + * @param {Browser} browserElement - The browser element + */ + #configureBrowserElement(browserElement) { + const rect = window.windowUtils.getBoundsWithoutFlushing(this.browserWrapper.parentElement); + const minWidth = rect.width * 0.85; + const minHeight = rect.height * 0.85; + + browserElement.style.minWidth = `${minWidth}px`; + browserElement.style.minHeight = `${minHeight}px`; + } + + /** + * Get the transform origin for the animation + * @param {Object} data - Glance data with position and dimensions + * @returns {string} The transform origin CSS value + */ + #getTransformOrigin(data) { + const { clientX, clientY, width, height } = data; + const parentRect = window.windowUtils.getBoundsWithoutFlushing( + this.browserWrapper.parentElement + ); + const xPercent = ((clientX + width / 2 - parentRect.left) / parentRect.width) * 100; + const yPercent = ((clientY + height / 2 - parentRect.top) / parentRect.height) * 100; + + const xOrigin = xPercent < 33 ? 'left' : xPercent > 66 ? 'right' : 'center'; + const yOrigin = yPercent < 33 ? 'top' : yPercent > 66 ? 'bottom' : 'center'; + + return `${xOrigin} ${yOrigin}`; + } + + /** + * Execute the main glance animation + * @param {Object} data - Glance data + * @param {Browser} browserElement - The browser element + * @param {Function} resolve - Promise resolve function + */ + #executeGlanceAnimation(data, browserElement, resolve) { + const imageDataElement = this.#handleElementPreview(data); + + // Create curved animation sequence + const arcSequence = this.#createGlanceArcSequence(data, 'opening'); + const transformOrigin = this.#getTransformOrigin(data); + + this.browserWrapper.style.transformOrigin = transformOrigin; + gZenUIManager.motion + .animate(this.browserWrapper, arcSequence, { + duration: gZenUIManager.testingEnabled ? 0 : 0.4, + ease: 'easeInOut', + }) + .then(() => { + this.#finalizeGlanceOpening(imageDataElement, browserElement, resolve); + }); + } + + /** + * Create arc animation sequence for glance animations + * @param {Object} data - Glance data with position and dimensions + * @param {string} direction - 'opening' or 'closing' + * @returns {Object} Animation sequence object + */ + #createGlanceArcSequence(data, direction) { + const { clientX, clientY, width, height } = data; + + // Calculate start and end positions based on direction + let startPosition, endPosition; + + const tabPanelsRect = window.windowUtils.getBoundsWithoutFlushing(gBrowser.tabpanels); + + const widthPercent = 0.85; + if (direction === 'opening') { + startPosition = { + x: clientX + width / 2, + y: clientY + height / 2, + width: width, + height: height, + }; + endPosition = { + x: tabPanelsRect.width / 2, + y: tabPanelsRect.height / 2, + width: tabPanelsRect.width * widthPercent, + height: tabPanelsRect.height, + }; + } else { + // closing + startPosition = { + x: tabPanelsRect.width / 2, + y: tabPanelsRect.height / 2, + width: tabPanelsRect.width * widthPercent, + height: tabPanelsRect.height, + }; + endPosition = { + x: clientX + width / 2, + y: clientY + height / 2, + width: width, + height: height, + }; + } + + // Calculate distance and arc parameters + const distance = this.#calculateDistance(startPosition, endPosition); + const { arcHeight, shouldArcDownward } = this.#calculateOptimalArc( + startPosition, + endPosition, + distance + ); + + const sequence = { + top: [], + left: [], + width: [], + height: [], + transform: [], + }; + + const steps = this.#ARC_CONFIG.ARC_STEPS; + const arcDirection = shouldArcDownward ? 1 : -1; + + function easeInOutQuad(t) { + return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; + } + + function easeOutCubic(t) { + return 1 - Math.pow(1 - t, 6); + } + + // First, create the main animation steps + for (let i = 0; i <= steps; i++) { + const progress = i / steps; + const eased = direction === 'opening' ? easeInOutQuad(progress) : easeOutCubic(progress); + + // Calculate size interpolation + const currentWidth = + startPosition.width + (endPosition.width - startPosition.width) * eased; + const currentHeight = + startPosition.height + (endPosition.height - startPosition.height) * eased; + + // Calculate position on arc + const distanceX = endPosition.x - startPosition.x; + const distanceY = endPosition.y - startPosition.y; + + const x = startPosition.x + distanceX * eased; + const y = + startPosition.y + + distanceY * eased + + arcDirection * arcHeight * (1 - (2 * eased - 1) ** 2); + + sequence.transform.push(`translate(-50%, -50%) scale(1)`); + sequence.top.push(`${y}px`); + sequence.left.push(`${x}px`); + sequence.width.push(`${currentWidth}px`); + sequence.height.push(`${currentHeight}px`); + } + + let scale = 1; + const bounceSteps = 40; + if (direction === 'opening') { + for (let i = 0; i < bounceSteps; i++) { + const progress = i / bounceSteps; + // Scale up slightly then back to normal + scale = 1 + 0.006 * Math.sin(progress * Math.PI); + // If we are at the last step, ensure scale is exactly 1 + if (i === bounceSteps - 1) { + scale = 1; + } + sequence.transform.push(`translate(-50%, -50%) scale(${scale})`); + sequence.top.push(sequence.top[sequence.top.length - 1]); + sequence.left.push(sequence.left[sequence.left.length - 1]); + sequence.width.push(sequence.width[sequence.width.length - 1]); + sequence.height.push(sequence.height[sequence.height.length - 1]); + } + } + + return sequence; + } + + /** + * Calculate distance between two positions + * @param {Object} start - Start position + * @param {Object} end - End position + * @returns {number} Distance + */ + #calculateDistance(start, end) { + const distanceX = end.x - start.x; + const distanceY = end.y - start.y; + return Math.sqrt(distanceX * distanceX + distanceY * distanceY); + } + + /** + * Calculate optimal arc parameters + * @param {Object} startPosition - Start position + * @param {Object} endPosition - End position + * @param {number} distance - Distance between positions + * @returns {Object} Arc parameters + */ + #calculateOptimalArc(startPosition, endPosition, distance) { + // Calculate available space for the arc + const availableTopSpace = Math.min(startPosition.y, endPosition.y); + const viewportHeight = window.innerHeight; + const availableBottomSpace = viewportHeight - Math.max(startPosition.y, endPosition.y); + + // Determine if we should arc downward or upward based on available space + const shouldArcDownward = availableBottomSpace > availableTopSpace; + + // Use the space in the direction we're arcing + const availableSpace = shouldArcDownward ? availableBottomSpace : availableTopSpace; + + // Limit arc height to a percentage of the available space + const arcHeight = Math.min( + distance * this.#ARC_CONFIG.ARC_HEIGHT_RATIO, + this.#ARC_CONFIG.MAX_ARC_HEIGHT, + availableSpace * 0.6 + ); + + return { arcHeight, shouldArcDownward }; + } + + /** + * Finalize the glance opening process + * @param {Element|null} imageDataElement - The preview element + * @param {Browser} browserElement - The browser element + * @param {Function} resolve - Promise resolve function + */ + #finalizeGlanceOpening(imageDataElement, browserElement, resolve) { + if (imageDataElement) { + imageDataElement.remove(); + } + + this.browserWrapper.style.transformOrigin = ''; + + browserElement.style.minWidth = ''; + browserElement.style.minHeight = ''; + + gBrowser.tabContainer._invalidateCachedTabs(); + this.overlay.style.removeProperty('overflow'); + this.browserWrapper.removeAttribute('animate'); + this.browserWrapper.setAttribute('has-finished-animation', true); + + this.#setAnimationState(false); + this.#currentTab.dispatchEvent(new Event('GlanceOpen', { bubbles: true })); + resolve(this.#currentTab); + } + + /** + * Clear container styles while preserving inset + * @param {Element} container - The container element + */ + #clearContainerStyles(container) { const inset = container.style.inset; container.removeAttribute('style'); container.style.inset = inset; } + /** + * Close the current glance + * @param {Object} options - Close options + * @param {boolean} options.noAnimation - Skip animation + * @param {boolean} options.onTabClose - Called during tab close + * @param {string} options.setNewID - Set new glance ID + * @param {boolean} options.hasFocused - Has focus confirmation + * @param {boolean} options.skipPermitUnload - Skip unload permission check + * @returns {Promise|undefined} Promise if animated, undefined if immediate + */ closeGlance({ noAnimation = false, onTabClose = false, setNewID = null, - isDifferent = false, hasFocused = false, skipPermitUnload = false, } = {}) { - if ( - (this._animating && !onTabClose) || - !this.#currentBrowser || - (this.animatingOpen && !onTabClose) || - this._duringOpening - ) { + if (!this.#canCloseGlance(onTabClose)) { return; } - if (!skipPermitUnload) { - let { permitUnload } = this.#currentBrowser.permitUnload(); - if (!permitUnload) { - return; - } + if (!skipPermitUnload && !this.#checkPermitUnload()) { + return; } const browserSidebarContainer = this.#currentParentTab?.linkedBrowser?.closest( '.browserSidebarContainer' ); const sidebarButtons = this.browserWrapper.querySelector('.zen-glance-sidebar-container'); + + if (this.#handleConfirmationTimeout(onTabClose, hasFocused, sidebarButtons)) { + return; + } + + this.browserWrapper.removeAttribute('has-finished-animation'); + + if (noAnimation) { + this.#clearContainerStyles(browserSidebarContainer); + this.quickCloseGlance({ closeCurrentTab: false }); + return; + } + + return this.#animateGlanceClosing( + onTabClose, + browserSidebarContainer, + sidebarButtons, + setNewID + ); + } + + /** + * Check if glance can be closed + * @param {boolean} onTabClose - Whether this is called during tab close + * @returns {boolean} True if can close + */ + #canCloseGlance(onTabClose) { + return !( + (this._animating && !onTabClose) || + !this.#currentBrowser || + (this.animatingOpen && !onTabClose) || + this._duringOpening + ); + } + + /** + * Check if unload is permitted + * @returns {boolean} True if unload is permitted + */ + #checkPermitUnload() { + const { permitUnload } = this.#currentBrowser.permitUnload(); + return permitUnload; + } + + /** + * Handle confirmation timeout for focused close + * @param {boolean} onTabClose - Whether this is called during tab close + * @param {boolean} hasFocused - Has focus confirmation + * @param {Element} sidebarButtons - The sidebar buttons element + * @returns {boolean} True if should return early + */ + #handleConfirmationTimeout(onTabClose, hasFocused, sidebarButtons) { if (onTabClose && hasFocused && !this.#confirmationTimeout && sidebarButtons) { - const cancelButton = sidebarButtons?.querySelector('.zen-glance-sidebar-close'); + const cancelButton = sidebarButtons.querySelector('.zen-glance-sidebar-close'); cancelButton.setAttribute('waitconfirmation', true); this.#confirmationTimeout = setTimeout(() => { cancelButton.removeAttribute('waitconfirmation'); this.#confirmationTimeout = null; }, 3000); - return; - } - - this.browserWrapper.removeAttribute('has-finished-animation'); - if (noAnimation) { - this._clearContainerStyles(browserSidebarContainer); - this.quickCloseGlance({ closeCurrentTab: false }); - return; + return true; } + return false; + } + /** + * Animate the glance closing process + * @param {boolean} onTabClose - Whether this is called during tab close + * @param {Element} browserSidebarContainer - The sidebar container + * @param {Element} sidebarButtons - The sidebar buttons + * @param {string} setNewID - New glance ID to set + * @returns {Promise} Promise that resolves when closing is complete + */ + #animateGlanceClosing(onTabClose, browserSidebarContainer, sidebarButtons, setNewID) { this.closingGlance = true; this._animating = true; gBrowser.moveTabAfter(this.#currentTab, this.#currentParentTab); - let quikcCloseZen = false; - if (onTabClose) { - // Create new tab if no more ex - if (gBrowser.tabs.length === 1) { - BrowserCommands.openTab(); - return; - } + if (onTabClose && gBrowser.tabs.length === 1) { + BrowserCommands.openTab(); + return; } - // do NOT touch here, I don't know what it does, but it works... + this.#prepareGlanceForClosing(); + this.#animateSidebarButtons(sidebarButtons); + this.#animateParentBackgroundClose(browserSidebarContainer); + + return this.#executeClosingAnimation(setNewID, onTabClose); + } + + /** + * Prepare glance for closing + */ + #prepareGlanceForClosing() { + // Critical: This line must not be touched - it works for unknown reasons this.#currentTab.style.display = 'none'; this.overlay.setAttribute('fade-out', true); this.overlay.style.pointerEvents = 'none'; this.quickCloseGlance({ justAnimateParent: true, clearID: false }); - const originalPosition = this.#glances.get(this.#currentGlanceID).originalPosition; + } + + /** + * Animate sidebar buttons out + * @param {Element} sidebarButtons - The sidebar buttons element + */ + #animateSidebarButtons(sidebarButtons) { if (sidebarButtons) { gZenUIManager.motion .animate( sidebarButtons, - { - opacity: [1, 0], - }, + { opacity: [1, 0] }, { duration: 0.2, type: 'spring', @@ -317,6 +793,13 @@ sidebarButtons.remove(); }); } + } + + /** + * Animate parent background restoration + * @param {Element} browserSidebarContainer - The sidebar container + */ + #animateParentBackgroundClose(browserSidebarContainer) { gZenUIManager.motion .animate( browserSidebarContainer, @@ -325,102 +808,199 @@ opacity: [0.6, 1], }, { - duration: 0.4, + duration: 0.3, type: 'spring', - bounce: 0.2, + bounce: 0, } ) .then(() => { - this._clearContainerStyles(browserSidebarContainer); + this.#clearContainerStyles(browserSidebarContainer); }); + this.browserWrapper.style.opacity = 1; + } + + /** + * Execute the main closing animation + * @param {string} setNewID - New glance ID to set + * @param {boolean} onTabClose - Whether this is called during tab close + * @returns {Promise} Promise that resolves when complete + */ + #executeClosingAnimation(setNewID, onTabClose) { return new Promise((resolve) => { + const originalPosition = this.#glances.get(this.#currentGlanceID).originalPosition; + const elementImageData = this.#glances.get(this.#currentGlanceID).elementImageData; + + this.#addElementPreview(elementImageData); + + // Create curved closing animation sequence + const closingData = this.#createClosingDataFromOriginalPosition(originalPosition); + const arcSequence = this.#createGlanceArcSequence(closingData, 'closing'); + gZenUIManager.motion - .animate( - this.browserWrapper, - { - ...originalPosition, - opacity: 0, - }, - { type: 'spring', bounce: 0, duration: 0.5, easing: 'ease-in' } - ) + .animate(this.browserWrapper, arcSequence, { duration: 0.4, ease: 'easeOut' }) .then(() => { - this.browserWrapper.removeAttribute('animate'); - if (!this.#currentParentTab) { - return; + // Remove element preview after closing animation + const elementPreview = this.browserWrapper.querySelector('.zen-glance-element-preview'); + if (elementPreview) { + elementPreview.remove(); } - - if (!onTabClose || quikcCloseZen) { - this.quickCloseGlance({ clearID: false }); - } - this.overlay.removeAttribute('fade-out'); - this.browserWrapper.removeAttribute('animate'); - - const lastCurrentTab = this.#currentTab; - - this.overlay.classList.remove('zen-glance-overlay'); - gBrowser - ._getSwitcher() - .setTabStateNoAction(lastCurrentTab, gBrowser.AsyncTabSwitcher.STATE_UNLOADED); - - if (!onTabClose) { - this.#currentParentTab._visuallySelected = false; - } - - if ( - this.#currentParentTab.linkedBrowser && - !this.#currentParentTab.hasAttribute('split-view') - ) { - this.#currentParentTab.linkedBrowser.zenModeActive = false; - } - - // reset everything - this.browserWrapper = null; - this.overlay = null; - this.contentWrapper = null; - - lastCurrentTab.removeAttribute('zen-glance-tab'); - lastCurrentTab._closingGlance = true; - - if (!isDifferent) { - gBrowser.selectedTab = this.#currentParentTab; - } - this._ignoreClose = true; - lastCurrentTab.dispatchEvent(new Event('GlanceClose', { bubbles: true })); - gBrowser.removeTab(lastCurrentTab, { animate: true, skipPermitUnload: true }); - gBrowser.tabContainer._invalidateCachedTabs(); - - this.#currentParentTab.removeAttribute('glance-id'); - - this.#glances.delete(this.#currentGlanceID); - this.#currentGlanceID = setNewID; - - this._duringOpening = false; - - this._animating = false; - this.closingGlance = false; - - if (this.#currentGlanceID) { - this.quickOpenGlance(); - } - - resolve(); + this.#finalizeGlanceClosing(setNewID, resolve, onTabClose); }); }); } - quickOpenGlance() { - if (!this.#currentBrowser || this._duringOpening) { + /** + * Create closing data from original position for arc animation + * @param {Object} originalPosition - Original position object + * @returns {Object} Closing data object + */ + #createClosingDataFromOriginalPosition(originalPosition) { + // Parse the original position values + const top = parseFloat(originalPosition.top) || 0; + const left = parseFloat(originalPosition.left) || 0; + const width = parseFloat(originalPosition.width) || 0; + const height = parseFloat(originalPosition.height) || 0; + + return { + clientX: left - width / 2, + clientY: top - height / 2, + width: width, + height: height, + }; + } + + /** + * Add element preview if available + * @param {string} elementImageData - The element image data + */ + #addElementPreview(elementImageData) { + if (elementImageData) { + const imageDataElement = document.createXULElement('image'); + imageDataElement.setAttribute('src', elementImageData); + imageDataElement.classList.add('zen-glance-element-preview'); + this.browserWrapper.prepend(imageDataElement); + } + } + + /** + * Finalize the glance closing process + * @param {string} setNewID - New glance ID to set + * @param {Function} resolve - Promise resolve function + * @param {boolean} onTabClose - Whether this is called during tab close + */ + #finalizeGlanceClosing(setNewID, resolve, onTabClose) { + this.browserWrapper.removeAttribute('animate'); + + if (!this.#currentParentTab) { return; } - this._duringOpening = true; + if (!onTabClose) { + this.quickCloseGlance({ clearID: false }); + } + this.browserWrapper.style.display = 'none'; + this.overlay.removeAttribute('fade-out'); + this.browserWrapper.removeAttribute('animate'); + + const lastCurrentTab = this.#currentTab; + this.#cleanupGlanceElements(lastCurrentTab); + this.#resetGlanceState(setNewID); + + this.#setAnimationState(false); + this.closingGlance = false; + + if (this.#currentGlanceID) { + this.quickOpenGlance(); + } + + resolve(); + } + + /** + * Clean up glance DOM elements + * @param {Tab} lastCurrentTab - The tab being closed + */ + #cleanupGlanceElements(lastCurrentTab) { + this.overlay.classList.remove('zen-glance-overlay'); + gBrowser + ._getSwitcher() + .setTabStateNoAction(lastCurrentTab, gBrowser.AsyncTabSwitcher.STATE_UNLOADED); + + if (!this.#currentParentTab.selected) { + this.#currentParentTab._visuallySelected = false; + } + + if (gBrowser.selectedTab === lastCurrentTab) { + gBrowser.selectedTab = this.#currentParentTab; + } + + if ( + this.#currentParentTab.linkedBrowser && + !this.#currentParentTab.hasAttribute('split-view') + ) { + this.#currentParentTab.linkedBrowser.zenModeActive = false; + } + + // Reset overlay references + this.browserWrapper = null; + this.overlay = null; + this.contentWrapper = null; + + lastCurrentTab.removeAttribute('zen-glance-tab'); + lastCurrentTab._closingGlance = true; + + this.#ignoreClose = true; + lastCurrentTab.dispatchEvent(new Event('GlanceClose', { bubbles: true })); + gBrowser.removeTab(lastCurrentTab, { animate: true, skipPermitUnload: true }); + gBrowser.tabContainer._invalidateCachedTabs(); + } + + /** + * Reset glance state + * @param {string} setNewID - New glance ID to set + */ + #resetGlanceState(setNewID) { + this.#currentParentTab.removeAttribute('glance-id'); + this.#glances.delete(this.#currentGlanceID); + this.#currentGlanceID = setNewID; + this._duringOpening = false; + } + + /** + * Quickly open glance without animation + */ + quickOpenGlance() { + if (!this.#currentBrowser || this.#duringOpening) { + return; + } + + this.#duringOpening = true; + this.#configureGlanceElements(); + this.#setGlanceStates(); + this.#duringOpening = false; + } + + /** + * Configure glance DOM elements + */ + #configureGlanceElements() { const parentBrowserContainer = this.#currentParentTab.linkedBrowser.closest( '.browserSidebarContainer' ); + parentBrowserContainer.classList.add('zen-glance-background'); parentBrowserContainer.classList.remove('zen-glance-overlay'); parentBrowserContainer.classList.add('deck-selected'); + + this.overlay.classList.add('deck-selected'); + this.overlay.classList.add('zen-glance-overlay'); + } + + /** + * Set glance browser and tab states + */ + #setGlanceStates() { this.#currentParentTab.linkedBrowser.zenModeActive = true; this.#currentParentTab.linkedBrowser.docShellIsActive = true; this.#currentBrowser.zenModeActive = true; @@ -428,13 +1008,16 @@ this.#currentBrowser.setAttribute('zen-glance-selected', true); this.fillOverlay(this.#currentBrowser); this.#currentParentTab._visuallySelected = true; - - this.overlay.classList.add('deck-selected'); - this.overlay.classList.add('zen-glance-overlay'); - - this._duringOpening = false; } + /** + * Quickly close glance without animation + * @param {Object} options - Close options + * @param {boolean} options.closeCurrentTab - Close current tab + * @param {boolean} options.closeParentTab - Close parent tab + * @param {boolean} options.justAnimateParent - Only animate parent + * @param {boolean} options.clearID - Clear current glance ID + */ quickCloseGlance({ closeCurrentTab = true, closeParentTab = true, @@ -445,127 +1028,217 @@ const browserContainer = this.#currentParentTab.linkedBrowser.closest( '.browserSidebarContainer' ); - if (parentHasBrowser) { - browserContainer.classList.remove('zen-glance-background'); - } + + this.#removeParentBackground(parentHasBrowser, browserContainer); + if (!justAnimateParent && this.overlay) { - if (parentHasBrowser && !this.#currentParentTab.hasAttribute('split-view')) { - if (closeParentTab) { - browserContainer.classList.remove('deck-selected'); - } - this.#currentParentTab.linkedBrowser.zenModeActive = false; - } - this.#currentBrowser.zenModeActive = false; - if (closeParentTab && parentHasBrowser) { - this.#currentParentTab.linkedBrowser.docShellIsActive = false; - } - if (closeCurrentTab) { - this.#currentBrowser.docShellIsActive = false; - this.overlay.classList.remove('deck-selected'); - this.#currentTab._selected = false; - } - if (!this.#currentParentTab._visuallySelected && closeParentTab) { - this.#currentParentTab._visuallySelected = false; - } - this.#currentBrowser.removeAttribute('zen-glance-selected'); - this.overlay.classList.remove('zen-glance-overlay'); + this.#resetGlanceStates( + closeCurrentTab, + closeParentTab, + parentHasBrowser, + browserContainer + ); } + if (clearID) { this.#currentGlanceID = null; } } + /** + * Remove parent background styling + * @param {boolean} parentHasBrowser - Whether parent has browser + * @param {Element} browserContainer - The browser container + */ + #removeParentBackground(parentHasBrowser, browserContainer) { + if (parentHasBrowser) { + browserContainer.classList.remove('zen-glance-background'); + } + } + + /** + * Reset glance states + * @param {boolean} closeCurrentTab - Whether to close current tab + * @param {boolean} closeParentTab - Whether to close parent tab + * @param {boolean} parentHasBrowser - Whether parent has browser + * @param {Element} browserContainer - The browser container + */ + #resetGlanceStates(closeCurrentTab, closeParentTab, parentHasBrowser, browserContainer) { + if (parentHasBrowser && !this.#currentParentTab.hasAttribute('split-view')) { + if (closeParentTab) { + browserContainer.classList.remove('deck-selected'); + } + this.#currentParentTab.linkedBrowser.zenModeActive = false; + } + + this.#currentBrowser.zenModeActive = false; + + if (closeParentTab && parentHasBrowser) { + this.#currentParentTab.linkedBrowser.docShellIsActive = false; + } + + if (closeCurrentTab) { + this.#currentBrowser.docShellIsActive = false; + this.overlay.classList.remove('deck-selected'); + this.#currentTab._selected = false; + } + + if (!this.#currentParentTab._visuallySelected && closeParentTab) { + this.#currentParentTab._visuallySelected = false; + } + + this.#currentBrowser.removeAttribute('zen-glance-selected'); + this.overlay.classList.remove('zen-glance-overlay'); + } + + /** + * Open glance on location change if not animating + */ onLocationChangeOpenGlance() { if (!this.animatingOpen) { this.quickOpenGlance(); } } - // note: must be sync to avoid timing issues + /** + * Handle location change events + * Note: Must be sync to avoid timing issues + * @param {Event} event - The location change event + */ onLocationChange(event) { const tab = event.target; + if (this.animatingFullOpen || this.closingGlance) { return; } - if (this._duringOpening || !tab.hasAttribute('glance-id')) { - if (this.#currentGlanceID && !this._duringOpening) { + + if (this.#duringOpening || !tab.hasAttribute('glance-id')) { + if (this.#currentGlanceID && !this.#duringOpening) { this.quickCloseGlance(); } return; } + if (this.#currentGlanceID && this.#currentGlanceID !== tab.getAttribute('glance-id')) { this.quickCloseGlance(); } + this.#currentGlanceID = tab.getAttribute('glance-id'); + if (gBrowser.selectedTab === this.#currentParentTab && this.#currentBrowser) { - const curTab = this.#currentTab; - const prevTab = event.detail.previousTab; - setTimeout(() => { - gBrowser.selectedTab = curTab; - if (prevTab?.linkedBrowser) { - prevTab.linkedBrowser - .closest('.browserSidebarContainer') - .classList.remove('deck-selected'); - } - }, 0); + this.#handleParentTabSelection(event); } else if (gBrowser.selectedTab === this.#currentTab) { setTimeout(this.onLocationChangeOpenGlance.bind(this), 0); } } + /** + * Handle parent tab selection + * @param {Event} event - The location change event + */ + #handleParentTabSelection(event) { + const curTab = this.#currentTab; + const prevTab = event.detail.previousTab; + + setTimeout(() => { + gBrowser.selectedTab = curTab; + if (prevTab?.linkedBrowser) { + prevTab.linkedBrowser + .closest('.browserSidebarContainer') + .classList.remove('deck-selected'); + } + }, 0); + } + + /** + * Handle tab close events + * @param {Event} event - The tab close event + */ onTabClose(event) { if (event.target === this.#currentParentTab) { this.closeGlance({ onTabClose: true }); } } + /** + * Manage tab close for glance tabs + * @param {Tab} tab - The tab being closed + * @returns {boolean} Whether to continue with tab close + */ manageTabClose(tab) { - if (tab.hasAttribute('glance-id')) { - const oldGlanceID = this.#currentGlanceID; - const newGlanceID = tab.getAttribute('glance-id'); - this.#currentGlanceID = newGlanceID; - const isDifferent = newGlanceID !== oldGlanceID; - if (this._ignoreClose) { - this._ignoreClose = false; - return false; - } - this.closeGlance({ - onTabClose: true, - setNewID: isDifferent ? oldGlanceID : null, - isDifferent, - }); - // only keep continueing tab close if we are not on the currently selected tab - return !isDifferent; + if (!tab.hasAttribute('glance-id')) { + return false; } - return false; + + const oldGlanceID = this.#currentGlanceID; + const newGlanceID = tab.getAttribute('glance-id'); + this.#currentGlanceID = newGlanceID; + const isDifferent = newGlanceID !== oldGlanceID; + + if (this.#ignoreClose) { + this.#ignoreClose = false; + return false; + } + + this.closeGlance({ + onTabClose: true, + setNewID: isDifferent ? oldGlanceID : null, + }); + + // Only continue tab close if we are not on the currently selected tab + return !isDifferent; } + /** + * Check if two tabs have different domains + * @param {Tab} tab1 - First tab + * @param {nsIURI} url2 - Second URL + * @returns {boolean} True if domains differ + */ tabDomainsDiffer(tab1, url2) { try { if (!tab1) { return true; } - let url1 = tab1.linkedBrowser.currentURI.spec; + + const url1 = tab1.linkedBrowser.currentURI.spec; if (url1.startsWith('about:')) { return true; } - // https://github.com/zen-browser/desktop/issues/7173: Only glance up links that are http(s) or file + + // Only glance up links that are http(s) or file + // https://github.com/zen-browser/desktop/issues/7173 const url2Spec = url2.spec; - if ( - !url2Spec.startsWith('http') && - !url2Spec.startsWith('https') && - !url2Spec.startsWith('file') - ) { + if (!this.#isValidGlanceUrl(url2Spec)) { return false; } + return Services.io.newURI(url1).host !== url2.host; } catch { return true; } } + /** + * Check if URL is valid for glance + * @param {string} urlSpec - The URL spec + * @returns {boolean} True if valid + */ + #isValidGlanceUrl(urlSpec) { + return ( + urlSpec.startsWith('http') || urlSpec.startsWith('https') || urlSpec.startsWith('file') + ); + } + + /** + * Check if a tab should be opened in glance + * @param {Tab} tab - The tab to check + * @param {nsIURI} uri - The URI to check + * @returns {boolean} True if should open in glance + */ shouldOpenTabInGlance(tab, uri) { - let owner = tab.owner; + const owner = tab.owner; + return ( owner && owner.pinned && @@ -576,86 +1249,138 @@ ); } + /** + * Handle tab open events + * @param {Browser} browser - The browser element + * @param {nsIURI} uri - The URI being opened + */ onTabOpen(browser, uri) { - let tab = gBrowser.getTabForBrowser(browser); + const tab = gBrowser.getTabForBrowser(browser); if (!tab) { return; } + try { if (this.shouldOpenTabInGlance(tab, uri)) { - const browserRect = gBrowser.tabbox.getBoundingClientRect(); - this.openGlance( - { - url: undefined, - ...(gZenUIManager._lastClickPosition || { - clientX: browserRect.width / 2, - clientY: browserRect.height / 2, - }), - width: 0, - height: 0, - }, - tab, - tab.owner - ); + this.#openGlanceForTab(tab); } } catch (e) { - console.error(e); + console.error('Error opening glance for tab:', e); } } + /** + * Open glance for a specific tab + * @param {Tab} tab - The tab to open glance for + */ + #openGlanceForTab(tab) { + const browserRect = window.windowUtils.getBoundsWithoutFlushing(gBrowser.tabbox); + const clickPosition = gZenUIManager._lastClickPosition || { + clientX: browserRect.width / 2, + clientY: browserRect.height / 2, + }; + + this.openGlance( + { + url: undefined, + ...clickPosition, + width: 0, + height: 0, + }, + tab, + tab.owner + ); + } + + /** + * Finish opening glance and clean up + */ finishOpeningGlance() { gBrowser.tabContainer._invalidateCachedTabs(); gZenWorkspaces.updateTabsContainers(); this.overlay.classList.remove('zen-glance-overlay'); - this._clearContainerStyles(this.browserWrapper); + this.#clearContainerStyles(this.browserWrapper); this.animatingFullOpen = false; this.closeGlance({ noAnimation: true, skipPermitUnload: true }); this.#glances.delete(this.#currentGlanceID); } + /** + * Fully open glance (convert to regular tab) + * @param {Object} options - Options for full opening + * @param {boolean} options.forSplit - Whether this is for split view + */ async fullyOpenGlance({ forSplit = false } = {}) { - // If there is no active glance, do nothing if (!this.#currentGlanceID || !this.#currentTab) { return; } + this.animatingFullOpen = true; this.#currentTab.setAttribute('zen-dont-split-glance', true); + this.#handleZenFolderPinning(); + gBrowser.moveTabAfter(this.#currentTab, this.#currentParentTab); + + const browserRect = window.windowUtils.getBoundsWithoutFlushing(this.browserWrapper); + this.#prepareTabForFullOpen(); + + const sidebarButtons = this.browserWrapper.querySelector('.zen-glance-sidebar-container'); + if (sidebarButtons) { + sidebarButtons.remove(); + } + + if (forSplit) { + this.finishOpeningGlance(); + return; + } + + if (gReduceMotion) { + gZenViewSplitter.deactivateCurrentSplitView(); + this.finishOpeningGlance(); + return; + } + + await this.#animateFullOpen(browserRect); + this.finishOpeningGlance(); + } + + /** + * Handle Zen folder pinning if applicable + */ + #handleZenFolderPinning() { const isZenFolder = this.#currentParentTab?.group?.isZenFolder; if (Services.prefs.getBoolPref('zen.folders.owned-tabs-in-folder') && isZenFolder) { gBrowser.pinTab(this.#currentTab); } + } - gBrowser.moveTabAfter(this.#currentTab, this.#currentParentTab); - - const browserRect = window.windowUtils.getBoundsWithoutFlushing(this.browserWrapper); + /** + * Prepare tab for full opening + */ + #prepareTabForFullOpen() { this.#currentTab.removeAttribute('zen-glance-tab'); - this._clearContainerStyles(this.browserWrapper); + this.#clearContainerStyles(this.browserWrapper); this.#currentTab.removeAttribute('glance-id'); this.#currentParentTab.removeAttribute('glance-id'); gBrowser.selectedTab = this.#currentTab; + this.#currentParentTab.linkedBrowser .closest('.browserSidebarContainer') .classList.remove('zen-glance-background'); this.#currentParentTab._visuallySelected = false; gBrowser.TabStateFlusher.flush(this.#currentTab.linkedBrowser); - const sidebarButtons = this.browserWrapper.querySelector('.zen-glance-sidebar-container'); - if (sidebarButtons) { - sidebarButtons.remove(); - } - if (forSplit) { - this.finishOpeningGlance(); - return; - } - if (gReduceMotion || forSplit) { - gZenViewSplitter.deactivateCurrentSplitView(); - this.finishOpeningGlance(); - return; - } - // Write the styles early to avoid flickering + } + + /** + * Animate the full opening process + * @param {Object} browserRect - The browser rectangle + */ + async #animateFullOpen(browserRect) { + // Write styles early to avoid flickering this.browserWrapper.style.opacity = 1; this.browserWrapper.style.width = `${browserRect.width}px`; this.browserWrapper.style.height = `${browserRect.height}px`; + await gZenUIManager.motion.animate( this.browserWrapper, { @@ -668,67 +1393,114 @@ bounce: 0, } ); + this.browserWrapper.style.width = ''; this.browserWrapper.style.height = ''; this.browserWrapper.style.opacity = ''; gZenViewSplitter.deactivateCurrentSplitView({ removeDeckSelected: true }); - this.finishOpeningGlance(); } + /** + * Open glance for bookmark activation + * @param {Event} event - The bookmark click event + * @returns {boolean} False to prevent default behavior + */ openGlanceForBookmark(event) { const activationMethod = Services.prefs.getStringPref('zen.glance.activation-method', 'ctrl'); - if (activationMethod === 'ctrl' && !event.ctrlKey) { - return; - } else if (activationMethod === 'alt' && !event.altKey) { - return; - } else if (activationMethod === 'shift' && !event.shiftKey) { - return; - } else if (activationMethod === 'meta' && !event.metaKey) { - return; - } else if (activationMethod === 'mantain' || typeof activationMethod === 'undefined') { + if (!this.#isActivationKeyPressed(event, activationMethod)) { return; } event.preventDefault(); event.stopPropagation(); - const rect = event.target.getBoundingClientRect(); - const data = { + const data = this.#createGlanceDataFromBookmark(event); + this.openGlance(data); + + return false; + } + + /** + * Check if the correct activation key is pressed + * @param {Event} event - The event + * @param {string} activationMethod - The activation method + * @returns {boolean} True if key is pressed + */ + #isActivationKeyPressed(event, activationMethod) { + const keyMap = { + ctrl: event.ctrlKey, + alt: event.altKey, + shift: event.shiftKey, + meta: event.metaKey, + }; + + return keyMap[activationMethod] || false; + } + + /** + * Create glance data from bookmark event + * @param {Event} event - The bookmark event + * @returns {Object} Glance data object + */ + #createGlanceDataFromBookmark(event) { + const rect = window.windowUtils.getBoundsWithoutFlushing(event.target); + return { url: event.target._placesNode.uri, clientX: rect.left, clientY: rect.top, width: rect.width, height: rect.height, }; - - this.openGlance(data); - - return false; } + /** + * Get the focused tab based on direction + * @param {number} aDir - Direction (-1 for parent, 1 for current) + * @returns {Tab} The focused tab + */ getFocusedTab(aDir) { return aDir < 0 ? this.#currentParentTab : this.#currentTab; } + /** + * Split the current glance into a split view + */ async splitGlance() { - if (this.#currentGlanceID) { - const currentTab = this.#currentTab; - const currentParentTab = this.#currentParentTab; + if (!this.#currentGlanceID) { + return; + } - const isZenFolder = currentParentTab?.group?.isZenFolder; - if (Services.prefs.getBoolPref('zen.folders.owned-tabs-in-folder') && isZenFolder) { - gBrowser.pinTab(currentTab); - } - await this.fullyOpenGlance({ forSplit: true }); - gZenViewSplitter.splitTabs([currentTab, currentParentTab], 'vsep', 1); - const browserContainer = currentTab.linkedBrowser?.closest('.browserSidebarContainer'); - if (!gReduceMotion && browserContainer) { - gZenViewSplitter.animateBrowserDrop(browserContainer); - } + const currentTab = this.#currentTab; + const currentParentTab = this.#currentParentTab; + + this.#handleZenFolderPinningForSplit(currentParentTab); + await this.fullyOpenGlance({ forSplit: true }); + + gZenViewSplitter.splitTabs([currentTab, currentParentTab], 'vsep', 1); + + const browserContainer = currentTab.linkedBrowser?.closest('.browserSidebarContainer'); + if (!gReduceMotion && browserContainer) { + gZenViewSplitter.animateBrowserDrop(browserContainer); } } + /** + * Handle Zen folder pinning for split view + * @param {Tab} parentTab - The parent tab + */ + #handleZenFolderPinningForSplit(parentTab) { + const isZenFolder = parentTab?.group?.isZenFolder; + if (Services.prefs.getBoolPref('zen.folders.owned-tabs-in-folder') && isZenFolder) { + gBrowser.pinTab(this.#currentTab); + } + } + + /** + * Get the tab or its glance parent + * @param {Tab} tab - The tab to check + * @returns {Tab} The tab or its parent + */ getTabOrGlanceParent(tab) { if (tab?.hasAttribute('glance-id') && this.#glances) { const parentTab = this.#glances.get(tab.getAttribute('glance-id'))?.parentTab; @@ -739,52 +1511,89 @@ return tab; } + /** + * Check if deck should remain selected + * @param {Element} currentPanel - Current panel + * @param {Element} oldPanel - Previous panel + * @returns {boolean} True if deck should remain selected + */ shouldShowDeckSelected(currentPanel, oldPanel) { - // Dont remove if it's a glance background and current panel corresponds to a glance const currentBrowser = currentPanel?.querySelector('browser'); const oldBrowser = oldPanel?.querySelector('browser'); + if (!currentBrowser || !oldBrowser) { return false; } + const currentTab = gBrowser.getTabForBrowser(currentBrowser); const oldTab = gBrowser.getTabForBrowser(oldBrowser); - if (currentTab && oldTab) { - const currentGlanceID = currentTab.getAttribute('glance-id'); - const oldGlanceID = oldTab.getAttribute('glance-id'); - if (currentGlanceID && oldGlanceID) { - return ( - currentGlanceID === oldGlanceID && oldPanel.classList.contains('zen-glance-background') - ); - } + + if (!currentTab || !oldTab) { + return false; } + + const currentGlanceID = currentTab.getAttribute('glance-id'); + const oldGlanceID = oldTab.getAttribute('glance-id'); + + if (currentGlanceID && oldGlanceID) { + return ( + currentGlanceID === oldGlanceID && oldPanel.classList.contains('zen-glance-background') + ); + } + return false; } + /** + * Handle search select command + * @param {string} where - Where to open the search result + */ onSearchSelectCommand(where) { - // Check if Glance is globally enabled and specifically enabled for contextmenu/search - if ( - !Services.prefs.getBoolPref('zen.glance.enabled', false) || - !Services.prefs.getBoolPref('zen.glance.enable-contextmenu-search', true) - ) { + if (!this.#isGlanceEnabledForSearch()) { return; } + if (where !== 'tab') { return; } + const currentTab = gBrowser.selectedTab; const parentTab = currentTab.owner; + if (!parentTab || parentTab.hasAttribute('glance-id')) { return; } - // Open a new glance if the current tab is a glance tab - const browserRect = gBrowser.tabbox.getBoundingClientRect(); + + this.#openGlanceForSearch(currentTab, parentTab); + } + + /** + * Check if glance is enabled for search + * @returns {boolean} True if enabled + */ + #isGlanceEnabledForSearch() { + return ( + Services.prefs.getBoolPref('zen.glance.enabled', false) && + Services.prefs.getBoolPref('zen.glance.enable-contextmenu-search', true) + ); + } + + /** + * Open glance for search result + * @param {Tab} currentTab - Current tab + * @param {Tab} parentTab - Parent tab + */ + #openGlanceForSearch(currentTab, parentTab) { + const browserRect = window.windowUtils.getBoundsWithoutFlushing(gBrowser.tabbox); + const clickPosition = gZenUIManager._lastClickPosition || { + clientX: browserRect.width / 2, + clientY: browserRect.height / 2, + }; + this.openGlance( { url: undefined, - ...(gZenUIManager._lastClickPosition || { - clientX: browserRect.width / 2, - clientY: browserRect.height / 2, - }), + ...clickPosition, width: 0, height: 0, }, @@ -796,6 +1605,9 @@ window.gZenGlanceManager = new nsZenGlanceManager(); + /** + * Register window actors for glance functionality + */ function registerWindowActors() { gZenActorsManager.addJSWindowActor('ZenGlance', { parent: { diff --git a/src/zen/glance/actors/ZenGlanceChild.sys.mjs b/src/zen/glance/actors/ZenGlanceChild.sys.mjs index c1cb21a6e..d78805321 100644 --- a/src/zen/glance/actors/ZenGlanceChild.sys.mjs +++ b/src/zen/glance/actors/ZenGlanceChild.sys.mjs @@ -2,11 +2,10 @@ // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at http://mozilla.org/MPL/2.0/. export class ZenGlanceChild extends JSWindowActorChild { + #activationMethod; + constructor() { super(); - - this.mouseUpListener = this.handleMouseUp.bind(this); - this.mouseDownListener = this.handleMouseDown.bind(this); this.clickListener = this.handleClick.bind(this); } @@ -22,51 +21,34 @@ export class ZenGlanceChild extends JSWindowActorChild { } } - async getActivationMethod() { - if (this._activationMethod === undefined) { - this._activationMethod = await this.sendQuery('ZenGlance:GetActivationMethod'); - } - return this._activationMethod; - } - - async getHoverActivationDelay() { - if (this._hoverActivationDelay === undefined) { - this._hoverActivationDelay = await this.sendQuery('ZenGlance:GetHoverActivationDelay'); - } - return this._hoverActivationDelay; + async #initActivationMethod() { + this.#activationMethod = await this.sendQuery('ZenGlance:GetActivationMethod'); } async initiateGlance() { this.mouseIsDown = false; - const activationMethod = await this.getActivationMethod(); - if (activationMethod === 'mantain') { - this.contentWindow.addEventListener('mousedown', this.mouseDownListener); - this.contentWindow.addEventListener('mouseup', this.mouseUpListener); - - this.contentWindow.document.removeEventListener('click', this.clickListener); - } else if ( - activationMethod === 'ctrl' || - activationMethod === 'alt' || - activationMethod === 'shift' - ) { - this.contentWindow.document.addEventListener('click', this.clickListener, { capture: true }); - - this.contentWindow.removeEventListener('mousedown', this.mouseDownListener); - this.contentWindow.removeEventListener('mouseup', this.mouseUpListener); - } + await this.#initActivationMethod(); + this.contentWindow.document.addEventListener('click', this.clickListener, { capture: true }); } ensureOnlyKeyModifiers(event) { return !(event.ctrlKey ^ event.altKey ^ event.shiftKey ^ event.metaKey); } - openGlance(target) { + openGlance(target, originalTarget) { let url = target.href; // Add domain to relative URLs if (!url.match(/^(?:[a-z]+:)?\/\//i)) { url = this.contentWindow.location.origin + url; } - const rect = target.getBoundingClientRect(); + // Get the largest element we can get. If the `A` element + // is a parent of the original target, use the anchor element, + // otherwise use the original target. + let rect = originalTarget.getBoundingClientRect(); + const anchorRect = target.getBoundingClientRect(); + if (anchorRect.width * anchorRect.height > rect.width * rect.height) { + rect = anchorRect; + } this.sendAsyncMessage('ZenGlance:OpenGlance', { url, clientX: rect.left, @@ -76,35 +58,11 @@ export class ZenGlanceChild extends JSWindowActorChild { }); } - handleMouseUp(event) { - if (this.hasClicked) { - event.preventDefault(); - event.stopPropagation(); - this.hasClicked = false; - } - this.mouseIsDown = null; - } - - async handleMouseDown(event) { - const target = event.target.closest('A'); - if (!target) { - return; - } - this.mouseIsDown = target; - const hoverActivationDelay = await this.getHoverActivationDelay(); - this.contentWindow.setTimeout(() => { - if (this.mouseIsDown === target) { - this.hasClicked = true; - this.openGlance(target); - } - }, hoverActivationDelay); - } - handleClick(event) { if (this.ensureOnlyKeyModifiers(event) || event.button !== 0 || event.defaultPrevented) { return; } - const activationMethod = this._activationMethod; + const activationMethod = this.#activationMethod; if (activationMethod === 'ctrl' && !event.ctrlKey) { return; } else if (activationMethod === 'alt' && !event.altKey) { @@ -113,8 +71,6 @@ export class ZenGlanceChild extends JSWindowActorChild { return; } else if (activationMethod === 'meta' && !event.metaKey) { return; - } else if (activationMethod === 'mantain' || typeof activationMethod === 'undefined') { - return; } // get closest A element const target = event.target.closest('A'); @@ -122,7 +78,7 @@ export class ZenGlanceChild extends JSWindowActorChild { event.preventDefault(); event.stopPropagation(); - this.openGlance(target); + this.openGlance(target, event.originalTarget || event.target); } } diff --git a/src/zen/glance/actors/ZenGlanceParent.sys.mjs b/src/zen/glance/actors/ZenGlanceParent.sys.mjs index b6d5c9b12..0939cd47a 100644 --- a/src/zen/glance/actors/ZenGlanceParent.sys.mjs +++ b/src/zen/glance/actors/ZenGlanceParent.sys.mjs @@ -11,9 +11,6 @@ export class ZenGlanceParent extends JSWindowActorParent { case 'ZenGlance:GetActivationMethod': { return Services.prefs.getStringPref('zen.glance.activation-method', 'ctrl'); } - case 'ZenGlance:GetHoverActivationDelay': { - return Services.prefs.getIntPref('zen.glance.hold-duration', 500); - } case 'ZenGlance:OpenGlance': { this.openGlance(this.browsingContext.topChromeWindow, message.data); break; @@ -31,7 +28,38 @@ export class ZenGlanceParent extends JSWindowActorParent { } } - openGlance(window, data) { + #imageBitmapToBase64(imageBitmap) { + // 1. Create a canvas with the same size as the ImageBitmap + const canvas = this.browsingContext.topChromeWindow.document.createElement('canvas'); + canvas.width = imageBitmap.width; + canvas.height = imageBitmap.height; + + // 2. Draw the ImageBitmap onto the canvas + const ctx = canvas.getContext('2d'); + ctx.drawImage(imageBitmap, 0, 0); + + // 3. Convert the canvas content to a Base64 string (PNG by default) + const base64String = canvas.toDataURL('image/png'); + return base64String; + } + + async openGlance(window, data) { + const win = this.browsingContext.topChromeWindow; + const tabPanels = win.gBrowser.tabpanels; + // Make the rect relative to the tabpanels. We dont do it directly on the + // content process since it does not take into account scroll. This way, we can + // be sure that the coordinates are correct. + const tabPanelsRect = tabPanels.getBoundingClientRect(); + const rect = new DOMRect( + data.clientX + tabPanelsRect.left, + data.clientY + tabPanelsRect.top, + data.width, + data.height + ); + const elementData = await this.#imageBitmapToBase64( + await win.browsingContext.currentWindowGlobal.drawSnapshot(rect, 1, 'transparent', true) + ); + data.elementData = elementData; window.gZenGlanceManager.openGlance(data); } } diff --git a/src/zen/glance/zen-glance.css b/src/zen/glance/zen-glance.css index f59f20b79..55bb59932 100644 --- a/src/zen/glance/zen-glance.css +++ b/src/zen/glance/zen-glance.css @@ -14,11 +14,11 @@ gap: 12px; max-width: 56px; - :root[zen-right-side='true'] & { + :root:not([zen-right-side='true']) & { left: 100%; } - :root:not([zen-right-side='true']) & { + :root[zen-right-side='true'] & { right: 100%; } @@ -99,7 +99,7 @@ } .browserSidebarContainer.zen-glance-background, -.browserSidebarContainer.zen-glance-overlay .browserContainer { +.browserSidebarContainer.zen-glance-overlay .browserContainer:not([fade-out='true']) { border-radius: var(--zen-native-inner-radius); box-shadow: var(--zen-big-shadow); } @@ -116,14 +116,19 @@ } & .browserContainer { - background: light-dark(rgb(255, 255, 255), rgb(32, 32, 32)); + transform: translate(-50%, -50%); position: fixed; - opacity: 0; top: 0; left: 0; flex: unset !important; /* Promote to its own layer during transitions to reduce jank */ - will-change: transform, opacity, top, left, width, height; + will-change: transform, top, left; + width: 85%; + height: 100%; + + &:not([has-finished-animation='true']) #statuspanel { + display: none; + } &[has-finished-animation='true'] { position: relative !important; @@ -140,10 +145,15 @@ } & browser { + background: light-dark(rgb(255, 255, 255), rgb(32, 32, 32)) !important; width: 100%; height: 100%; opacity: 1; - transition: opacity 0.2s ease-in-out; + transition: opacity 0.08s; + + @starting-style { + opacity: 0; + } } &[animate='true'] { @@ -153,8 +163,17 @@ &[fade-out='true'] { & browser { - transition: opacity 0.2s ease; + transition: opacity 0.25s ease-in-out; opacity: 0; } } } + +.zen-glance-element-preview { + position: absolute; + pointer-events: none; + width: 100%; + height: 100%; + z-index: -1; + border-radius: var(--zen-native-inner-radius); +} diff --git a/src/zen/split-view/ZenViewSplitter.mjs b/src/zen/split-view/ZenViewSplitter.mjs index 2dd3c6424..9e8436691 100644 --- a/src/zen/split-view/ZenViewSplitter.mjs +++ b/src/zen/split-view/ZenViewSplitter.mjs @@ -958,10 +958,7 @@ class nsZenViewSplitter extends nsZenDOMOperatedFeature { * @returns {Element} The tab browser panel. */ get tabBrowserPanel() { - if (!this._tabBrowserPanel) { - this._tabBrowserPanel = document.getElementById('tabbrowser-tabpanels'); - } - return this._tabBrowserPanel; + return gBrowser.tabpanels; } get splitViewActive() { diff --git a/src/zen/tests/glance/browser.toml b/src/zen/tests/glance/browser.toml index 7f231bbff..6098fbf07 100644 --- a/src/zen/tests/glance/browser.toml +++ b/src/zen/tests/glance/browser.toml @@ -13,3 +13,4 @@ support-files = [ ["browser_glance_next_tab.js"] ["browser_glance_prev_tab.js"] ["browser_glance_select_parent.js"] +["browser_glance_close_select.js"] diff --git a/src/zen/tests/glance/browser_glance_close_select.js b/src/zen/tests/glance/browser_glance_close_select.js new file mode 100644 index 000000000..e073ec53a --- /dev/null +++ b/src/zen/tests/glance/browser_glance_close_select.js @@ -0,0 +1,36 @@ +/* Any copyright is dedicated to the Public Domain. + https://creativecommons.org/publicdomain/zero/1.0/ */ + +'use strict'; + +async function openAndCloseGlance() { + await openGlanceOnTab(async (glanceTab) => { + ok( + glanceTab.hasAttribute('zen-glance-tab'), + 'The glance tab should have the zen-glance-tab attribute' + ); + }); +} + +add_task(async function test_Glance_Close_No_Tabs() { + const currentTab = gBrowser.selectedTab; + await openAndCloseGlance(); + Assert.equal(gBrowser.selectedTab, currentTab, 'The original tab should be selected'); + ok(currentTab.selected, 'The original tab should be visually selected'); +}); + +add_task(async function test_Glance_Close_With_Next_Tab() { + const originalTab = gBrowser.selectedTab; + await BrowserTestUtils.withNewTab( + { url: 'http://example.com', gBrowser, waitForLoad: false }, + async function () { + const selectedTab = gBrowser.selectedTab; + Assert.notEqual(selectedTab, originalTab, 'A new tab should be selected'); + await openAndCloseGlance(); + Assert.equal(gBrowser.selectedTab, selectedTab, 'The new tab should still be selected'); + ok(selectedTab.selected, 'The new tab should be visually selected'); + + gBrowser.selectedTab = originalTab; + } + ); +}); diff --git a/src/zen/tests/ignorePrefs.json b/src/zen/tests/ignorePrefs.json index 25c2cf37a..b7244e654 100644 --- a/src/zen/tests/ignorePrefs.json +++ b/src/zen/tests/ignorePrefs.json @@ -1,6 +1,14 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// This file lists preferences that are ignored when running mochitests. +// Add here any preference that is not relevant for testing Zen Modus. +// This prevents unnecessary test re-runs when these preferences are changed. [ "zen.mods.updated-value-observer", "zen.mods.last-update", "zen.view.compact.enable-at-startup", + "zen.urlbar.suggestions-learner", "browser.newtabpage.activity-stream.trendingSearch.defaultSearchEngine" ]