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 @@