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"
]