Re-use expand/collapse animation logic in collection tree
This commit is contained in:
parent
9ceaac18b1
commit
8eeb675f80
4 changed files with 144 additions and 112 deletions
|
@ -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,24 +2271,44 @@ 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._saveOpenStates();
|
||||
this.tree.invalidate();
|
||||
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) {
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Reference in a new issue