Re-use expand/collapse animation logic in collection tree

This commit is contained in:
Tom Najdek 2024-01-25 18:15:33 +01:00
parent 9ceaac18b1
commit 8eeb675f80
No known key found for this signature in database
GPG key ID: EEC61A7B4C667D77
4 changed files with 144 additions and 112 deletions

View file

@ -404,16 +404,7 @@ var CollectionTree = class CollectionTree extends LibraryTree {
}
}
// since row has been re-rendered, if it has been toggled open/close, we need to force twisty animation
if (this._lastToggleOpenStateIndex === index) {
let twisty = div.querySelector('.twisty');
if (twisty) {
twisty.classList.toggle('open', !this.isContainerOpen(index));
setTimeout(() => {
twisty.classList.toggle('open', this.isContainerOpen(index));
}, 0);
}
}
this._animateExpandCollapse(index, div);
return div;
}
@ -1159,10 +1150,11 @@ var CollectionTree = class CollectionTree extends LibraryTree {
toggleOpenState = async (index) => {
if (this.isContainerEmpty(index)) return;
this._lastToggleOpenStateIndex = index;
// cleanup after ongoing animations, if any is running
this._animation?.callback?.();
if (this.isContainerOpen(index)) {
await this._closeContainer(index);
this._lastToggleOpenStateIndex = null;
return;
}
@ -1180,8 +1172,13 @@ var CollectionTree = class CollectionTree extends LibraryTree {
this._rows[index].isOpen = true;
this._refreshRowMap();
this._saveOpenStates();
this._animation = {
index, count, isOpen: true,
callback: () => {
this._animation = null;
}
};
this.tree.invalidate(index);
this._lastToggleOpenStateIndex = null;
}
/**
@ -2274,25 +2271,45 @@ var CollectionTree = class CollectionTree extends LibraryTree {
return treeRow && !(treeRow.isSeparator() || treeRow.isHeader());
}
_closeContainer(row, skipMap) {
if (!this.isContainerOpen(row) || this.isContainerEmpty(row)) return;
_closeContainer(index, skipRowMapRefresh) {
if (!this.isContainerOpen(index) || this.isContainerEmpty(index)) return;
this.selection.selectEventsSuppressed = true;
var level = this.getLevel(row);
var nextRow = row + 1;
var count = 0;
var level = this.getLevel(index);
let rowsToRemove = [];
// Remove child rows
while ((nextRow < this._rows.length) && (this.getLevel(nextRow) > level)) {
this._removeRow(nextRow, true);
for (let i = 1; i < this._rows.length; i++) {
if (index + i >= this._rows.length || this.getLevel(index + i) <= level) {
break;
}
rowsToRemove.push(index + i);
count++;
}
this.selection.selectEventsSuppressed = false;
this._rows[row].isOpen = false;
this._refreshRowMap();
this._rows[index].isOpen = false;
if (skipRowMapRefresh) {
this._removeRows(rowsToRemove);
}
else {
this._animation = {
index, count, isOpen: false,
callback: async () => {
this._animation = null; // ensure animation is cleared before next render
this._removeRows(rowsToRemove);
await this._refreshPromise;
this._saveOpenStates();
this.tree.invalidate();
}
};
this.tree.invalidate();
}
}
_getIcon(index) {
let iconName = this.getIconName(index);

View file

@ -44,7 +44,6 @@ const CHILD_INDENT = 16;
const COLORED_TAGS_RE = new RegExp("^(?:Numpad|Digit)([0-" + Zotero.Tags.MAX_COLORED_TAGS + "]{1})$");
const COLUMN_PREFS_FILEPATH = OS.Path.join(Zotero.Profile.dir, "treePrefs.json");
const ATTACHMENT_STATE_LOAD_DELAY = 150; //ms
const SLIDE_ANIMATION_DURATION = 200; //ms
var ItemTree = class ItemTree extends LibraryTree {
static async init(domEl, opts={}) {
@ -118,7 +117,6 @@ var ItemTree = class ItemTree extends LibraryTree {
}
this._itemTreeLoadingDeferred = Zotero.Promise.defer();
this._animation = null;
}
unregister() {
@ -2840,88 +2838,7 @@ var ItemTree = class ItemTree extends LibraryTree {
div.setAttribute('aria-disabled', true);
}
div.style.zIndex = null;
div.style.transition = null;
div.style.transform = null;
if (this._animation !== null) {
if (this._animation?.index === index) {
// there is a chance that twisty row will get re-rendered while
// transition is playing (e.g. user changes selection). In such
// case we need to make sure that twisty row is on top of other
// rows but we no longer try to play transition to avoid
// animation stutter
div.style.zIndex = '1';
if (!this._animation.twistyAnimated) {
this._animation.twistyAnimated = true;
let twisty = div.querySelector('.twisty');
if (twisty) {
// since row has been re-rendered, if it has been toggled
// open/close, we need to force twisty animation. We do this by
// setting the opposite state and then toggling it back
twisty.classList.toggle('open', !this.isContainerOpen(index));
setTimeout(() => {
twisty.classList.toggle('open', this.isContainerOpen(index));
}, 0);
}
}
}
if (this._animation?.index !== null && index > this._animation.index && this._animation?.count !== null && this._animation.count > 0) {
const needsTransform = !div.style.transform;
if (needsTransform) {
let delay = 0;
let duration = SLIDE_ANIMATION_DURATION;
if (this._animation.isOpen) {
if (index < 1 + this._animation.index + this._animation.count) {
// new rows need to slide sequentially (initially all are squashed behind the parent row)
const newRowIndex = index - this._animation.index;
const remainingNewRowsCount = this._animation.count - newRowIndex;
delay = (remainingNewRowsCount / this._animation.count) * SLIDE_ANIMATION_DURATION;
duration = SLIDE_ANIMATION_DURATION - delay;
// hide all new rows behind the parent row before animating
div.style.transform = `translateY(${-(index - this._animation.index) * 100}%)`;
}
else {
// move the remaining rows down
div.style.transform = `translateY(${-(this._animation.count) * 100}%)`;
}
setTimeout(() => {
div.style.transition = `transform ${duration / 1000}s linear ${delay / 1000}s`;
div.style.transform = '';
}, 0);
}
else {
// eslint-disable-next-line no-lonely-if
if (index < 1 + this._animation.index + this._animation.count) {
// animate collapsed rows up and hide behind parent row
div.style.transform = `translateY(${-(index - this._animation.index) * 100}%)`;
const newRowIndex = index - this._animation.index;
delay = 0;
duration = (newRowIndex / this._animation.count) * SLIDE_ANIMATION_DURATION;
}
else {
// move the remaining rows up
div.style.transform = `translateY(${-(this._animation.count) * 100}%)`;
}
div.style.transition = `transform ${duration / 1000}s linear ${delay / 1000}s`;
this._pendingCallback = this._animation.callback;
}
// cleanup and callback
setTimeout(() => {
if (this._animation?.callback) {
// first time callback is called it must clear this._animation so it doesn't get called again
this._animation.callback();
}
div.style.transition = null;
div.style.transform = null;
}, SLIDE_ANIMATION_DURATION);
}
}
}
this._animateExpandCollapse(index, div);
return div;
}
@ -2976,7 +2893,7 @@ var ItemTree = class ItemTree extends LibraryTree {
// Remove child rows
for (let i = 1; i < this._rows.length; i++) {
if (this.getLevel(index + i) <= level) {
if (index + i >= this._rows.length || this.getLevel(index + i) <= level) {
break;
}
rowsToRemove.push(index + i);

View file

@ -26,6 +26,8 @@
const { TreeSelectionStub } = require('components/virtualized-table');
const React = require('react');
const SLIDE_ANIMATION_DURATION = 200;
/**
* Common methods for Zotero.ItemTree and Zotero.CollectionTree
* @type {Zotero.LibraryTree}
@ -41,6 +43,8 @@ var LibraryTree = class LibraryTree extends React.Component {
this.onSelect = this.createEventBinding('select');
this.onRefresh = this.createEventBinding('refresh');
this._animation = null;
}
get window() {
@ -221,6 +225,90 @@ var LibraryTree = class LibraryTree extends React.Component {
});
}
_animateExpandCollapse = (index, div) => {
div.style.zIndex = null;
div.style.transition = null;
div.style.transform = null;
if (this._animation !== null) {
if (this._animation?.index === index) {
// there is a chance that twisty row will get re-rendered while
// transition is playing (e.g. user changes selection). In such
// case we need to make sure that twisty row is on top of other
// rows but we no longer try to play transition to avoid
// animation stutter
div.style.zIndex = '1';
if (!this._animation.twistyAnimated) {
this._animation.twistyAnimated = true;
let twisty = div.querySelector('.twisty');
if (twisty) {
// since row has been re-rendered, if it has been toggled
// open/close, we need to force twisty animation. We do this by
// setting the opposite state and then toggling it back
twisty.classList.toggle('open', !this.isContainerOpen(index));
setTimeout(() => {
twisty.classList.toggle('open', this.isContainerOpen(index));
}, 0);
}
}
}
if (this._animation?.index !== null && index > this._animation.index && this._animation?.count !== null && this._animation.count > 0) {
const needsTransform = !div.style.transform;
if (needsTransform) {
let delay = 0;
let duration = SLIDE_ANIMATION_DURATION;
if (this._animation.isOpen) {
if (index < 1 + this._animation.index + this._animation.count) {
// new rows need to slide sequentially (initially all are squashed behind the parent row)
const newRowIndex = index - this._animation.index;
const remainingNewRowsCount = this._animation.count - newRowIndex;
delay = (remainingNewRowsCount / this._animation.count) * SLIDE_ANIMATION_DURATION;
duration = SLIDE_ANIMATION_DURATION - delay;
// hide all new rows behind the parent row before animating
div.style.transform = `translateY(${-(index - this._animation.index) * 100}%)`;
}
else {
// move the remaining rows down
div.style.transform = `translateY(${-(this._animation.count) * 100}%)`;
}
setTimeout(() => {
div.style.transition = `transform ${duration / 1000}s linear ${delay / 1000}s`;
div.style.transform = '';
}, 0);
}
else {
if (index < 1 + this._animation.index + this._animation.count) {
// animate collapsed rows up and hide behind parent row
div.style.transform = `translateY(${-(index - this._animation.index) * 100}%)`;
const newRowIndex = index - this._animation.index;
delay = 0;
duration = (newRowIndex / this._animation.count) * SLIDE_ANIMATION_DURATION;
}
else {
// move the remaining rows up
div.style.transform = `translateY(${-(this._animation.count) * 100}%)`;
}
div.style.transition = `transform ${duration / 1000}s linear ${delay / 1000}s`;
this._pendingCallback = this._animation.callback;
}
// cleanup and callback
setTimeout(() => {
if (this._animation?.callback) {
// first time callback is called it must clear this._animation so it doesn't get called again
this._animation.callback();
}
div.style.transition = null;
div.style.transform = null;
}, SLIDE_ANIMATION_DURATION);
}
}
}
};
updateHeight = Zotero.Utilities.debounce(this._updateHeight, 200);
updateFontSize() {

View file

@ -27,8 +27,10 @@ $icons: (
// virtualized-table has a default background (--material-background)
// which is what we want in most places, including dialogs that include
// #zotero-collections-tree, however main window collection tree is an
// exception
background: transparent;
// exception.
// Same applies to .row below, which needs to have a background-color set
// to something opaque to support expand/collapse animation.
background: var(--material-sidepane);
}
.virtualized-table {
@ -37,6 +39,14 @@ $icons: (
text-overflow: ellipsis;
.row {
&:not(.highlighted):not(.selected):not([role=none]) {
background: var(--material-background);
#main-window & {
background: var(--material-sidepane);
}
}
.icon-css:not(.twisty) {
width: 16px;
height: 16px;