diff --git a/src/browser/app/profile/zen-browser.js b/src/browser/app/profile/zen-browser.js index 00d8979ec..8b614996a 100644 --- a/src/browser/app/profile/zen-browser.js +++ b/src/browser/app/profile/zen-browser.js @@ -197,6 +197,10 @@ pref('zen.splitView.enable-tab-drop', true); pref('zen.splitView.min-resize-width', 7); pref('zen.splitView.rearrange-hover-size', 24); +// Zen Download Animations +pref('zen.animations.download-animation', true); +pref('zen.animations.download-animation-duration', 1200); // ms + // Startup flags pref('zen.startup.smooth-scroll-in-tabs', true); diff --git a/src/browser/base/content/zen-assets.jar.inc.mn b/src/browser/base/content/zen-assets.jar.inc.mn index cf0703510..0b8dde965 100644 --- a/src/browser/base/content/zen-assets.jar.inc.mn +++ b/src/browser/base/content/zen-assets.jar.inc.mn @@ -69,7 +69,9 @@ content/browser/zen-components/ZenMediaController.mjs (../../zen/media/ZenMediaController.mjs) content/browser/zen-styles/zen-media-controls.css (../../zen/media/zen-media-controls.css) - content/browser/zen-components/ZenDownloadAnimation.mjs (../../zen/downloads/ZenDownloadAnimation.mjs) + content/browser/zen-components/ZenDownloadAnimation.mjs (../../zen/animations/ZenDownloadAnimation.mjs) + content/browser/zen-styles/zen-download-animation.css (../../zen/animations/zen-download-animation.css) + # Images content/browser/zen-images/gradient.png (../../zen/images/gradient.png) @@ -80,7 +82,7 @@ content/browser/zen-images/grain-bg.png (../../zen/images/grain-bg.png) content/browser/zen-images/note-indicator.svg (../../zen/images/note-indicator.svg) - content/browser/zen-images/downloads/download.svg (../../zen/images/downloads/download.svg) + content/browser/zen-images/animations/download.svg (../../zen/images/animations/download.svg) # Fonts content/browser/zen-fonts/JunicodeVF-Italic.woff2 (../../zen/fonts/JunicodeVF-Italic.woff2) diff --git a/src/zen/animations/ZenDownloadAnimation.mjs b/src/zen/animations/ZenDownloadAnimation.mjs new file mode 100644 index 000000000..da02910a8 --- /dev/null +++ b/src/zen/animations/ZenDownloadAnimation.mjs @@ -0,0 +1,272 @@ +var { Downloads } = ChromeUtils.importESModule('resource://gre/modules/Downloads.sys.mjs'); + +{ + const CONFIG = Object.freeze({ + ANIMATION: { + ARC_STEPS: 60, + MAX_ARC_HEIGHT: 800, + ARC_HEIGHT_RATIO: 0.8, // Arc height = distance * ratio (capped at MAX_ARC_HEIGHT) + SCALE_END: 0.45, // Final scale at destination + }, + }); + + class ZenDownloadAnimation extends ZenDOMOperatedFeature { + async init() { + this._lastClickPosition = null; + this._setupClickListener(); + await this._setupDownloadListeners(); + } + + _setupClickListener() { + document.addEventListener('mousedown', this._handleClick.bind(this), true); + } + + _handleClick(event) { + this._lastClickPosition = { + clientX: event.clientX, + clientY: event.clientY, + }; + } + + async _setupDownloadListeners() { + try { + const list = await Downloads.getList(Downloads.ALL); + list.addView({ + onDownloadAdded: this._handleNewDownload.bind(this), + }); + } catch (error) { + console.error('Failed to set up download animation listeners:', error); + throw error; + } + } + + _handleNewDownload() { + if (!Services.prefs.getBoolPref('zen.animations.download-animation')) { + return; + } + + if (!this._lastClickPosition) { + console.warn('No recent click position available for animation'); + return; + } + + this._animateDownload(this._lastClickPosition); + } + + _animateDownload(startPosition) { + let animationElement = document.querySelector('zen-download-animation'); + if (!animationElement) { + animationElement = document.createElement('zen-download-animation'); + document.body.appendChild(animationElement); + } else { + animationElement.initializeAnimation(startPosition); + } + } + } + + class ZenDownloadAnimationElement extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this._loadStyles(); + } + + _loadStyles() { + const linkElem = document.createElement('link'); + linkElem.setAttribute('rel', 'stylesheet'); + linkElem.setAttribute('href', 'chrome://browser/content/zen-styles/zen-download-animation.css'); + this.shadowRoot.appendChild(linkElem); + } + + _createAnimationElement(startPosition) { + const animationHTML = ` +
+
+
+
+
+ `; + + const fragment = window.MozXULElement.parseXULToFragment(animationHTML); + const animationElement = fragment.querySelector('.zen-download-animation'); + + animationElement.style.left = `${startPosition.clientX}px`; + animationElement.style.top = `${startPosition.clientY}px`; + animationElement.style.transform = 'translate(-50%, -50%)'; + + this.shadowRoot.appendChild(animationElement); + + return animationElement; + } + + initializeAnimation(startPosition) { + if (!startPosition) { + console.warn('No start position provided, skipping animation'); + return; + } + + const downloadsButton = document.getElementById('downloads-button'); + + if (!downloadsButton) { + console.warn('Downloads button not found, skipping animation'); + return; + } + + const isVisible = this._isElementVisible(downloadsButton); + if (!isVisible) { + console.warn('Downloads button is not visible, skipping animation'); + return; + } + + const buttonRect = downloadsButton.getBoundingClientRect(); + const endPosition = { + clientX: buttonRect.left + buttonRect.width / 2, + clientY: buttonRect.top + buttonRect.height / 2, + }; + + const animationElement = this._createAnimationElement(startPosition); + const distance = this._calculateDistance(startPosition, endPosition); + + // Determine optimal arc direction based on available space + const { arcHeight, shouldArcDownward } = this._calculateOptimalArc(startPosition, endPosition, distance); + + this._runAnimationSequence(animationElement, startPosition, endPosition, arcHeight, shouldArcDownward); + } + + _calculateOptimalArc(startPosition, endPosition, distance) { + // Calculate available space for the arc + const availableTopSpace = Math.min(startPosition.clientY, endPosition.clientY); + const viewportHeight = window.innerHeight; + const availableBottomSpace = viewportHeight - Math.max(startPosition.clientY, endPosition.clientY); + + // 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 * CONFIG.ANIMATION.ARC_HEIGHT_RATIO, + CONFIG.ANIMATION.MAX_ARC_HEIGHT, + availableSpace * 0.8 + ); + + return { arcHeight, shouldArcDownward }; + } + + _calculateDistance(start, end) { + const distanceX = end.clientX - start.clientX; + const distanceY = end.clientY - start.clientY; + return Math.sqrt(distanceX * distanceX + distanceY * distanceY); + } + + _runAnimationSequence(element, start, end, arcHeight, shouldArcDownward) { + try { + const distanceX = end.clientX - start.clientX; + const distanceY = end.clientY - start.clientY; + + const arcAnimation = this._createArcAnimation(element, distanceX, distanceY, arcHeight, shouldArcDownward); + arcAnimation.onfinish = () => this._cleanupAnimation(element); + } catch (error) { + console.error('Error in animation sequence:', error); + this._cleanupAnimation(element); + } + } + + _createArcAnimation(element, distanceX, distanceY, arcHeight, shouldArcDownward) { + const keyframes = []; + const arcDirection = shouldArcDownward ? 1 : -1; + const steps = CONFIG.ANIMATION.ARC_STEPS; + const endScale = CONFIG.ANIMATION.SCALE_END; + + function easeInOutQuad(t) { + return t < 0.5 ? 2 * t * t : -1 + (4 - 2 * t) * t; + } + + let previousRotation = 0; + for (let i = 0; i <= steps; i++) { + const progress = i / steps; + const eased = easeInOutQuad(progress); + + // Calculate opacity changes + let opacity; + if (progress < 0.3) { + // Fade in during first 30% + opacity = 0.3 + (progress / 0.3) * 0.6; + } else if (progress < 0.98) { + // Slight increase to full opacity + opacity = 0.9 + ((progress - 0.3) / 0.6) * 0.1; + } else { + // Decrease opacity in the final steps + opacity = 1 - ((progress - 0.9) / 0.1) * 1; + } + + // Calculate scaling changes + let scale; + if (progress < 0.5) { + scale = 0.5 + (progress / 0.5) * 1.3; + } else { + scale = 1.8 - ((progress - 0.5) / 0.5) * (1.8 - endScale); + } + + // Position on arc + const x = distanceX * eased; + const y = distanceY * eased + arcDirection * arcHeight * (1 - (2 * eased - 1) ** 2); + + // Calculate rotation to point in the direction of movement + let rotation = previousRotation; + if (i > 0) { + const prevEased = easeInOutQuad((i - 1) / steps); + + const prevX = distanceX * prevEased; + const prevAdjustedProgress = prevEased * 2 - 1; + const prevVerticalOffset = arcDirection * arcHeight * (1 - prevAdjustedProgress * 2); + const prevY = distanceY * prevEased + prevVerticalOffset; + + const targetRotation = Math.atan2(y - prevY, x - prevX) * (180 / Math.PI); + + rotation += (targetRotation - previousRotation) * 0.01; + previousRotation = rotation; + } + + keyframes.push({ + offset: progress, + opacity: opacity, + transform: `translate(calc(${x}px - 50%), calc(${y}px - 50%)) rotate(${rotation}deg) scale(${scale})`, + }); + } + + return element.animate(keyframes, { + duration: Services.prefs.getIntPref('zen.animations.download-animation-duration'), + easing: 'cubic-bezier(0.37, 0, 0.63, 1)', + fill: 'forwards', + }); + } + + _cleanupAnimation(element) { + if (element && element.parentNode) { + element.parentNode.removeChild(element); + } else { + console.warn('Error cleaning download animation'); + } + } + + _isElementVisible(element) { + if (!element) return false; + + const rect = element.getBoundingClientRect(); + + // Element must be in the viewport + if (rect.bottom < 0 || rect.right < 0 || rect.top > window.innerHeight || rect.left > window.innerWidth) { + return false; + } + + return true; + } + } + + customElements.define('zen-download-animation', ZenDownloadAnimationElement); + + new ZenDownloadAnimation().init().catch(console.error); +} diff --git a/src/zen/animations/zen-download-animation.css b/src/zen/animations/zen-download-animation.css new file mode 100644 index 000000000..6fb0c4a90 --- /dev/null +++ b/src/zen/animations/zen-download-animation.css @@ -0,0 +1,42 @@ +:host { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 9999; +} + +.zen-download-animation { + position: absolute; + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + padding: 4px; + background-color: var(--zen-primary-color); +} + +.zen-download-animation-inner-circle { + position: relative; + width: 100%; + height: 100%; + border-radius: 50%; + background-color: black; +} + +.zen-download-animation-icon { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: var(--zen-primary-color); + -webkit-mask: url('chrome://browser/content/zen-images/animations/download.svg') no-repeat center; + -webkit-mask-size: 70%; + mask: url('chrome://browser/content/zen-images/animations/download.svg') no-repeat center; + mask-size: 70%; +} diff --git a/src/zen/downloads/ZenDownloadAnimation.mjs b/src/zen/downloads/ZenDownloadAnimation.mjs deleted file mode 100644 index ce3cb5c97..000000000 --- a/src/zen/downloads/ZenDownloadAnimation.mjs +++ /dev/null @@ -1,290 +0,0 @@ -var { Downloads } = ChromeUtils.importESModule('resource://gre/modules/Downloads.sys.mjs'); - -{ - const CONFIG = Object.freeze({ - ANIMATION: { - FADE_DURATION: 300, - ARC_STEPS: 60, - DURATION: 1500, - MAX_ARC_HEIGHT: 500, - ARC_HEIGHT_RATIO: 0.8, // Arc height = distance * ratio (capped at MAX_ARC_HEIGHT) - SCALE_END: 0.5, // Final scale at destination - }, - }); - - class ZenDownloadAnimation extends ZenDOMOperatedFeature { - async init() { - this._lastClickPosition = null; - this._lastClickTime = 0; - this._setupClickListener(); - await this._setupDownloadListeners(); - } - - _setupClickListener() { - document.addEventListener( - 'mousedown', - (event) => { - this._lastClickPosition = { - clientX: event.clientX, - clientY: event.clientY, - }; - }, - true - ); - } - - _getLastClickPosition() { - if (this._lastClickPosition) { - return this._lastClickPosition; - } - return null; - } - - async _setupDownloadListeners() { - try { - const list = await Downloads.getList(Downloads.ALL); - list.addView({ - onDownloadAdded: () => { - this._handleNewDownload(); - }, - }); - } catch (error) { - console.error('Failed to set up download listeners:', error); - throw error; - } - } - - _animateDownload(startPosition) { - const animationElement = document.querySelector('zen-download-animation'); - if (animationElement) { - animationElement.initializeAnimation(startPosition); - } else { - if (!document.querySelector('zen-download-animation')) { - const downloadAnimation = document.createElement('zen-download-animation'); - document.body.appendChild(downloadAnimation); - } - } - } - - _handleNewDownload() { - const clickPosition = this._getLastClickPosition(); - - if (!clickPosition) { - console.warn('No recent click position available for animation'); - return; - } - - this._animateDownload(clickPosition); - } - } - - class ZenDownloadAnimationElement extends HTMLElement { - constructor() { - super(); - this.attachShadow({ mode: 'open' }); - this._createStyles(); - } - _createStyles() { - const style = document.createElement('style'); - style.textContent = ` - :host { - position: fixed; - top: 0; - left: 0; - width: 100%; - height: 100%; - pointer-events: none; - z-index: 9999; - } - .download-animation { - position: absolute; - width: 32px; - height: 32px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - padding: 4px; - background-color: var(--zen-primary-color); - } - .download-animation-inner-circle { - position: relative; - width: 100%; - height: 100%; - border-radius: 50%; - background-color: black; - } - .download-animation-icon { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background-color: var(--zen-primary-color); - -webkit-mask: url("chrome://browser/content/zen-images/downloads/download.svg") no-repeat center; - -webkit-mask-size: 70%; - mask: url("chrome://browser/content/zen-images/downloads/download.svg") no-repeat center; - mask-size: 70%; - } - `; - this.shadowRoot.appendChild(style); - } - - _createAnimationElement(startPosition) { - const animationElement = document.createElement('div'); - animationElement.className = 'download-animation'; - animationElement.style.position = 'absolute'; - animationElement.style.left = `${startPosition.clientX}px`; - animationElement.style.top = `${startPosition.clientY}px`; - animationElement.style.transform = 'translate(-50%, -50%)'; - - const innerCircle = document.createElement('div'); - innerCircle.className = 'download-animation-inner-circle'; - - const icon = document.createElement('div'); - icon.className = 'download-animation-icon'; - - innerCircle.appendChild(icon); - animationElement.appendChild(innerCircle); - this.shadowRoot.appendChild(animationElement); - return animationElement; - } - - initializeAnimation(startPosition) { - if (!startPosition) { - console.warn('No start position provided, skipping animation'); - return; - } - - const downloadsButton = document.getElementById('downloads-button'); - - if (!downloadsButton) { - console.warn('Downloads button not found, skipping animation'); - return; - } - - const buttonRect = downloadsButton.getBoundingClientRect(); - const endPosition = { - clientX: buttonRect.left + buttonRect.width / 2, - clientY: buttonRect.top + buttonRect.height / 2, - }; - - const animationElement = this._createAnimationElement(startPosition); - - const distance = this._calculateDistance(startPosition, endPosition); - const arcHeight = Math.min(distance * CONFIG.ANIMATION.ARC_HEIGHT_RATIO, CONFIG.ANIMATION.MAX_ARC_HEIGHT); - - this._runAnimationSequence(animationElement, startPosition, endPosition, arcHeight); - } - - _calculateDistance(start, end) { - const distanceX = end.clientX - start.clientX; - const distanceY = end.clientY - start.clientY; - return Math.sqrt(distanceX * distanceX + distanceY * distanceY); - } - - _runAnimationSequence(element, start, end, arcHeight) { - try { - const distanceX = end.clientX - start.clientX; - const distanceY = end.clientY - start.clientY; - - this._runAnimation(element, distanceX, distanceY, arcHeight); - } catch (error) { - console.error('Error in animation sequence:', error); - this._cleanupAnimation(element); - } - } - - _runAnimation(element, distanceX, distanceY, arcHeight) { - this._createArcAnimation(element, distanceX, distanceY, arcHeight).onfinish = () => { - this._fadeOutAnimation(element); - }; - } - - _createArcAnimation(element, distanceX, distanceY, arcHeight) { - const keyframes = []; - const steps = CONFIG.ANIMATION.ARC_STEPS; - const endScale = CONFIG.ANIMATION.SCALE_END; - - const opacityValues = []; - const scaleValues = []; - - for (let i = 0; i <= steps; i++) { - const progress = i / steps; - - let opacity; - if (progress < 0.3) { - opacity = 0.3 + (progress / 0.3) * 0.6; - } else if (progress < 0.5) { - opacity = 0.9 + ((progress - 0.3) / 0.2) * 0.1; - } else { - opacity = 1; - } - opacityValues.push(opacity); - - let scale; - if (progress < 0.3) { - scale = 0.5 + (progress / 0.3) * 0.5; - } else if (progress < 0.5) { - scale = 1 + ((progress - 0.3) / 0.2) * 0.05; - } else { - scale = 1.05 - ((progress - 0.5) / 0.5) * (1.05 - endScale); - } - scaleValues.push(scale); - - const x = distanceX * progress; - - const adjustedProgress = progress * 2 - 1; // -1 to 1 - const verticalOffset = -arcHeight * (1 - adjustedProgress * adjustedProgress); - const y = distanceY * progress + verticalOffset; - - let rotation = 0; - let previousRotation = 0; - - if (i > 0 && i < steps) { - const prevProgress = (i - 1) / steps; - const prevX = distanceX * prevProgress; - const prevAdjustedProgress = prevProgress * 2 - 1; - const prevVerticalOffset = -arcHeight * (1 - prevAdjustedProgress * prevAdjustedProgress); - const prevY = distanceY * prevProgress + prevVerticalOffset; - - const targetRotation = Math.atan2(y - prevY, x - prevX) * (180 / Math.PI); - - rotation = previousRotation + (targetRotation - previousRotation) * 0.1; - - previousRotation = rotation; - } - - keyframes.push({ - offset: progress, - opacity: opacityValues[i], - transform: `translate(calc(${x}px - 50%), calc(${y}px - 50%)) rotate(${rotation}deg) scale(${scaleValues[i]})`, - }); - } - - return element.animate(keyframes, { - duration: CONFIG.ANIMATION.DURATION, - easing: 'cubic-bezier(0.37, 0, 0.63, 1)', - fill: 'forwards', - }); - } - - _fadeOutAnimation(element) { - element.animate([{ opacity: 1 }, { opacity: 0 }], { - duration: CONFIG.ANIMATION.FADE_DURATION, - fill: 'forwards', - }).onfinish = () => this._cleanupAnimation(element); - } - - _cleanupAnimation(element) { - if (element && element.parentNode) { - element.parentNode.removeChild(element); - } - } - } - - customElements.define('zen-download-animation', ZenDownloadAnimationElement); - - const zenDownloadAnimation = new ZenDownloadAnimation(); - zenDownloadAnimation.init().catch(console.error); -} diff --git a/src/zen/images/downloads/download.svg b/src/zen/images/animations/download.svg similarity index 79% rename from src/zen/images/downloads/download.svg rename to src/zen/images/animations/download.svg index 4a87eb25a..e233225a4 100644 --- a/src/zen/images/downloads/download.svg +++ b/src/zen/images/animations/download.svg @@ -1,5 +1,5 @@