gh-15422: Fixed broken folder animations (gh-15436)

This commit is contained in:
mr. m
2026-09-17 14:53:29 +02:00
committed by GitHub
parent 04e7db5d3a
commit f800d550cb
7 changed files with 293 additions and 65 deletions

View File

@@ -404,13 +404,18 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
return;
}
const collapsedRoot = group.rootMostCollapsedFolder;
let collapsedRoot = group.rootMostCollapsedFolder;
if (!collapsedRoot) {
return;
}
collapsedRoot.setAttribute("has-active", "true");
await this.animateSelect(collapsedRoot);
// If the folder got expanded instead, the tab may still be
// inside of another collapsed subfolder.
while (collapsedRoot) {
collapsedRoot.setAttribute("has-active", "true");
const expanded = await this.animateSelect(collapsedRoot);
collapsedRoot = expanded ? group.rootMostCollapsedFolder : null;
}
gBrowser.tabContainer._invalidateCachedTabs();
}
@@ -1505,22 +1510,28 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
#createAnimation(items, targetState, opts, callback = () => {}) {
items = Array.isArray(items) ? items : [items];
return items.map(item =>
this.#animateItem(item, targetState, opts).then(callback)
this.#animateItem(item, targetState, opts).then(
finished => finished && callback()
)
);
}
#itemAnimations = new WeakMap();
/**
* Animates an element to the given target state. An array value is a
* [from, to] pair, "auto" endpoints are resolved by measuring and an
* empty string animates back to the element's natural value. The final
* values are left applied as inline styles.
* values are left applied as inline styles. A running animation on the
* same element is replaced, continuing from its current values.
*
* @param {Element} item - The element to animate.
* @param {object} targetState - Property to value (or [from, to]) map.
* @param {object} opts - The animation options.
* @param {number} opts.duration - The duration in seconds.
* @param {string} opts.ease - The easing name.
* @returns {Promise} Resolves when the animation has finished.
* @returns {Promise<boolean>} Resolves when the animation has finished,
* false if it got replaced by another one before that.
*/
async #animateItem(item, targetState, { duration = 0.18, ease } = {}) {
const computed = window.getComputedStyle(item);
@@ -1539,13 +1550,18 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
const to = {};
const finalStyles = new Map();
for (const [prop, value] of Object.entries(targetState)) {
let [start, end] = Array.isArray(value) ? value : [undefined, value];
const start = Array.isArray(value) ? value[0] : undefined;
from[prop] =
start === undefined || start === "auto"
? measure(prop)
: toCssValue(prop, start);
}
// The start values are taken, the natural values need to be measured
// without the previous animation applying.
this.#itemAnimations.get(item)?.cancel();
for (const [prop, value] of Object.entries(targetState)) {
let end = Array.isArray(value) ? value[1] : value;
const cssProp = prop.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`);
if (start === undefined || start === "auto") {
start = measure(prop);
} else {
start = toCssValue(prop, start);
}
if (end === "" || end === "auto") {
// Resolve the natural value by clearing any inline override.
item.style.removeProperty(cssProp);
@@ -1555,18 +1571,18 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
end = toCssValue(prop, end);
finalStyles.set(cssProp, end);
}
from[prop] = start;
to[prop] = end;
}
const animation = item.animate([from, to], {
duration: duration * 1000,
easing: ease === "easeInOut" ? "ease-in-out" : "ease",
});
this.#itemAnimations.set(item, animation);
try {
await animation.finished;
} catch (e) {
// The animation was cancelled, leave the element as-is.
return;
// The animation was replaced, leave the element to the new one.
return false;
}
for (const [cssProp, value] of finalStyles) {
if (value === null) {
@@ -1575,6 +1591,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
item.style.setProperty(cssProp, value);
}
}
return true;
}
#calculateHeightShift(tabsContainer, selectedTabs) {
@@ -1757,49 +1774,53 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
}
}
const afterMarginTop = () => {
tabsContainer.style.overflowY = "";
if (group.hasAttribute("has-active")) {
const activeTabs = group.activeTabs;
const folders = new Map();
group.removeAttribute("has-active");
for (let tab of activeTabs) {
const tabGroup = tab?.group?.hasAttribute("split-view-group")
? tab?.group?.group
: tab?.group;
if (!folders.has(tabGroup?.id)) {
folders.set(tabGroup?.id, tabGroup?.activeGroups?.at(-1));
}
let activeGroup = folders.get(tabGroup?.id);
if (activeGroup) {
this.setFolderIndentation(
[tab],
activeGroup,
/* for collapse = */ true
);
// Update the active state and indentation right away instead of when
// the animation ends, it would otherwise be lost if the folder gets
// collapsed again before that.
if (group.hasAttribute("has-active")) {
const activeTabs = group.activeTabs;
group.removeAttribute("has-active");
for (const tab of activeTabs) {
const tabGroup = tab?.group?.hasAttribute("split-view-group")
? tab?.group?.group
: tab?.group;
const activeGroup = tabGroup?.activeGroups?.at(-1);
if (activeGroup) {
this.setFolderIndentation(
[tab],
activeGroup,
/* for collapse = */ true
);
} else {
// Since the folder is now expanded, we should remove active attribute
// to the tab that was previously visible
tab.removeAttribute("folder-active");
if (tab.group?.hasAttribute("split-view-group")) {
tab.group.style.removeProperty("--zen-folder-indent");
} else {
// Since the folder is now expanded, we should remove active attribute
// to the tab that was previously visible
tab.removeAttribute("folder-active");
if (tab.group?.hasAttribute("split-view-group")) {
tab.group.style.removeProperty("--zen-folder-indent");
} else {
tab.style.removeProperty("--zen-folder-indent");
}
tab.style.removeProperty("--zen-folder-indent");
}
}
folders.clear();
}
// Folder has been expanded and has no active tabs
group.activeTabs = [];
}
// Folder has been expanded and has no active tabs
group.activeTabs = [];
const afterMarginTop = () => {
tabsContainer.style.overflowY = "";
};
let duration = this.#folderAnimationDuration;
// Items of collapsed subfolders must stay hidden, don't animate them twice.
const itemsToReveal = itemsToShow.filter(
item => !itemsToHide.includes(item)
);
animations.push(
...this.#createAnimation(
itemsToShow,
{ opacity: "", height: "" },
itemsToReveal,
{ opacity: "", height: "", minHeight: "" },
{ duration, ease: "easeInOut" }
),
...this.#createAnimation(
@@ -1821,10 +1842,6 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
this.#animationCount += 1;
await Promise.all(animations);
this.#animationCount -= 1;
// Cleanup
this.styleCleanup(itemsToShow);
this.styleCleanup(itemsToHide);
}
async animateUnloadAll(group) {
@@ -1969,9 +1986,16 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
gBrowser.tabContainer._invalidateCachedTabs();
}
/**
* Shows the selected tabs of a collapsed folder.
*
* @param {MozTabbrowserTabGroup} group - The collapsed folder.
* @returns {Promise<boolean>} True if the folder got expanded instead,
* since all of its tabs are active.
*/
async animateSelect(group) {
if (!group?.isZenFolder) {
return;
return false;
}
this.cancelPopupTimer();
@@ -1989,7 +2013,24 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
if (group.collapsed && selectedTabs.length) {
const active = new Set([...(group.activeTabs ?? []), ...selectedTabs]);
const tabs = group.tabs.filter(tab => !tab.hasAttribute("zen-empty-tab"));
// Tabs inside of collapsed subfolders would stay hidden if the folder
// gets expanded, so they don't need to be active.
const isInsideCollapsedSubfolder = tab => {
let folder = tab.group?.hasAttribute("split-view-group")
? tab.group.group
: tab.group;
while (folder && folder !== group) {
if (folder.collapsed) {
return true;
}
folder = folder.group;
}
return false;
};
const tabs = group.tabs.filter(
tab =>
!tab.hasAttribute("zen-empty-tab") && !isInsideCollapsedSubfolder(tab)
);
if (
tabs.length &&
tabs.every(
@@ -2001,7 +2042,7 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
)
) {
group.collapsed = false;
return;
return true;
}
}
@@ -2160,11 +2201,12 @@ class nsZenFolders extends nsZenDOMOperatedFeature {
await Promise.all(animations);
this.#animationCount -= 1;
if (this.#animationCount) {
return;
return false;
}
// Cleanup
this.styleCleanup(selectedTabs);
return false;
}
animateGroupMove(group, expand = false) {

View File

@@ -1367,8 +1367,8 @@ export class nsZenThemePicker extends nsZenMultiWindowFeature {
if (themedColors.length === 2) {
if (!forToolbar) {
return [
`linear-gradient(${rotation}deg, ${this.#getSingleRGBColor(themedColors[1], forToolbar)} 20%, transparent 100%)`,
`linear-gradient(${rotation + 180}deg, ${this.#getSingleRGBColor(themedColors[0], forToolbar)} 20%, transparent 100%)`,
`linear-gradient(${rotation}deg, ${this.#getSingleRGBColor(themedColors[1], forToolbar)} 30%, transparent 120%)`,
`linear-gradient(${rotation + 180}deg, ${this.#getSingleRGBColor(themedColors[0], forToolbar)} 30%, transparent 120%)`,
]
.reverse()
.join(", ");

View File

@@ -44,10 +44,7 @@ add_task(async function test_Issue_9981() {
const tab2ResetButton = tab2.querySelector(".tab-reset-button");
tab2ResetButton.style.display = "flex";
await TestUtils.waitForCondition(
() => tab2ResetButton.getBoundingClientRect().width != 0
);
EventUtils.synthesizeMouseAtCenter(tab2ResetButton, {});
await clickWhenReady(tab2ResetButton);
await TestUtils.waitForCondition(
() => !tab2.hasAttribute("folder-active"),

View File

@@ -42,7 +42,10 @@ add_task(async function test_Issue_() {
"Tab 2 should be in the active folder"
);
EventUtils.synthesizeMouseAtCenter(folder.resetButton, {});
EventUtils.synthesizeMouseAtCenter(folder.labelElement, {
type: "mousemove",
});
await clickWhenReady(folder.resetButton);
await new Promise(resolve =>
/* eslint-disable mozilla/no-arbitrary-setTimeout */

View File

@@ -26,3 +26,175 @@ add_task(async function test_Create_Folder() {
await removeFolder(subfolder);
await removeFolder(parent);
});
add_task(async function test_Collapsed_Subfolder_Stays_Collapsed() {
const originalTab = gBrowser.selectedTab;
const activeTab = BrowserTestUtils.addTab(gBrowser, "data:text/html,tab1");
const hiddenTab = BrowserTestUtils.addTab(gBrowser, "data:text/html,tab2");
const parentTab = BrowserTestUtils.addTab(gBrowser, "data:text/html,tab3");
const subfolder = await gZenFolders.createFolder([activeTab, hiddenTab], {
renameFolder: false,
label: "subfolder",
});
const parent = await gZenFolders.createFolder([parentTab], {
renameFolder: false,
label: "parent",
});
parentTab.after(subfolder);
gBrowser.selectedTab = activeTab;
await new Promise(resolve => setTimeout(resolve, 0));
const height = tab => tab.getBoundingClientRect().height;
subfolder.collapsed = true;
await TestUtils.waitForCondition(
() => !height(hiddenTab),
"Unselected tab is hidden when the subfolder collapses"
);
parent.collapsed = true;
await TestUtils.waitForCondition(
() => !height(parentTab),
"Parent tab is hidden when the parent folder collapses"
);
parent.collapsed = false;
await TestUtils.waitForCondition(
() => height(parentTab) && !parent.hasAttribute("has-active"),
"Parent folder is expanded again"
);
await TestUtils.waitForTick();
ok(subfolder.collapsed, "Subfolder is still collapsed");
ok(height(activeTab), "Active tab of the subfolder is still visible");
Assert.equal(
height(hiddenTab),
0,
"Unselected tab of the collapsed subfolder stays hidden"
);
gBrowser.selectedTab = originalTab;
await removeFolder(subfolder);
await removeFolder(parent);
});
add_task(async function test_Spam_Toggle_Parent_Folder() {
const originalTab = gBrowser.selectedTab;
const activeTab = BrowserTestUtils.addTab(gBrowser, "data:text/html,tab1");
const hiddenTab = BrowserTestUtils.addTab(gBrowser, "data:text/html,tab2");
const parentTab = BrowserTestUtils.addTab(gBrowser, "data:text/html,tab3");
const subfolder = await gZenFolders.createFolder([activeTab, hiddenTab], {
renameFolder: false,
label: "subfolder",
});
const parent = await gZenFolders.createFolder([parentTab], {
renameFolder: false,
label: "parent",
});
parentTab.after(subfolder);
gBrowser.selectedTab = activeTab;
// createFolder sets the initial collapsed state on a timeout.
await new Promise(resolve => setTimeout(resolve, 0));
const height = tab => tab.getBoundingClientRect().height;
const indent = tab => tab.style.getPropertyValue("--zen-folder-indent");
subfolder.collapsed = true;
await TestUtils.waitForCondition(
() => !height(hiddenTab),
"Unselected tab is hidden when the subfolder collapses"
);
for (let i = 0; i < 3; i++) {
parent.collapsed = !parent.collapsed;
await new Promise(resolve => requestAnimationFrame(resolve));
}
await TestUtils.waitForCondition(
() => !height(parentTab),
"Parent tab is hidden when the parent folder ends up collapsed"
);
ok(parent.collapsed, "Parent folder is collapsed");
ok(parent.hasAttribute("has-active"), "Parent folder is still active");
Assert.deepEqual(parent.activeTabs, [activeTab], "Active tab is kept");
ok(height(activeTab), "Active tab is still visible");
Assert.equal(indent(activeTab), "0px", "Active tab lost its indentation");
Assert.equal(height(hiddenTab), 0, "Unselected tab stays hidden");
for (let i = 0; i < 3; i++) {
parent.collapsed = !parent.collapsed;
await new Promise(resolve => requestAnimationFrame(resolve));
}
await TestUtils.waitForCondition(
() => height(parentTab),
"Parent tab is visible when the parent folder ends up expanded"
);
ok(!parent.hasAttribute("has-active"), "Parent folder is not active");
ok(subfolder.hasAttribute("has-active"), "Subfolder is still active");
Assert.equal(indent(activeTab), "14px", "Active tab is indented again");
Assert.equal(height(hiddenTab), 0, "Unselected tab stays hidden");
gBrowser.selectedTab = originalTab;
await removeFolder(subfolder);
await removeFolder(parent);
});
add_task(async function test_Select_All_Tabs_Expands_Subfolder() {
const originalTab = gBrowser.selectedTab;
const tab1 = BrowserTestUtils.addTab(gBrowser, "data:text/html,tab1");
const tab2 = BrowserTestUtils.addTab(gBrowser, "data:text/html,tab2");
const tab3 = BrowserTestUtils.addTab(gBrowser, "data:text/html,tab3");
const subfolder = await gZenFolders.createFolder([tab2, tab3], {
renameFolder: false,
label: "subfolder",
});
const parent = await gZenFolders.createFolder([tab1], {
renameFolder: false,
label: "parent",
});
tab1.after(subfolder);
await new Promise(resolve => setTimeout(resolve, 0));
const height = tab => tab.getBoundingClientRect().height;
subfolder.collapsed = true;
gBrowser.selectedTab = tab1;
parent.collapsed = true;
await TestUtils.waitForCondition(
() => !height(tab2) && !height(tab3),
"Subfolder tabs are hidden when the parent folder collapses"
);
gBrowser.selectedTab = tab3;
await TestUtils.waitForCondition(
() => height(tab3) && !parent.collapsed,
"Parent folder is expanded when tab 3 is selected"
);
await TestUtils.waitForCondition(
() => !height(tab2),
"Tab 2 stays hidden inside of the collapsed subfolder"
);
ok(!parent.hasAttribute("has-active"), "Parent folder is not active");
ok(subfolder.collapsed, "Subfolder is still collapsed");
ok(subfolder.hasAttribute("has-active"), "Subfolder is active");
Assert.deepEqual(subfolder.activeTabs, [tab3], "Tab 3 is the active tab");
ok(!tab1.hasAttribute("folder-active"), "Tab 1 is not folder-active");
gBrowser.selectedTab = tab2;
await TestUtils.waitForCondition(
() => height(tab2),
"Tab 2 is visible when selected"
);
ok(!parent.collapsed, "Parent folder is expanded");
ok(!subfolder.collapsed, "Subfolder is expanded");
ok(!parent.hasAttribute("has-active"), "Parent folder is not active");
ok(!subfolder.hasAttribute("has-active"), "Subfolder is not active");
for (const tab of [tab1, tab2, tab3]) {
ok(!tab.hasAttribute("folder-active"), "Tab is not folder-active");
}
gBrowser.selectedTab = originalTab;
await removeFolder(subfolder);
await removeFolder(parent);
});

View File

@@ -32,3 +32,17 @@ async function addTabTo(
await BrowserTestUtils.browserLoaded(browser);
return tab;
}
async function clickWhenReady(button) {
await TestUtils.waitForCondition(() => {
const rect = button.getBoundingClientRect();
return (
rect.width &&
document.elementFromPoint(
rect.x + rect.width / 2,
rect.y + rect.height / 2
) === button
);
}, "Button should be clickable");
EventUtils.synthesizeMouseAtCenter(button, {});
}

View File

@@ -38,12 +38,12 @@ https_first_disabled = true
["browser_mute.js"]
["browser_mute2.js"]
["browser_mute_persist_navigation.js"]
["browser_mute_restore_closed_audible_tab.js"]
["browser_mute2.js"]
["browser_mute_webAudio.js"]
["browser_sound_indicator_silent_video.js"]