Tag selector performance overhaul
- Use react-virtualized to render tags on demand, reducing the number of DOM elements from potentially tens of thousands to <100. This requires tags to be absolutely positioned, so sizing and positioning need to be precomputed rather than relying on CSS. - Avoid unnecessary refreshes, speed up tag retrieval, and optimize sorting - Debounce reflowing when resizing tag selector Also: - Scroll to top when changing collections - Allow tags to take up full width of tag selector without truncation Closes #1649 Closes #281
This commit is contained in:
parent
c52589f96b
commit
d9cee322cd
18 changed files with 959 additions and 270 deletions
1
.babelrc
1
.babelrc
|
@ -9,6 +9,7 @@
|
||||||
"chrome/content/zotero/xpcom/citeproc.js",
|
"chrome/content/zotero/xpcom/citeproc.js",
|
||||||
"resource/react.js",
|
"resource/react.js",
|
||||||
"resource/react-dom.js",
|
"resource/react-dom.js",
|
||||||
|
"resource/react-virtualized.js",
|
||||||
"test/resource/*.js"
|
"test/resource/*.js"
|
||||||
],
|
],
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
|
|
@ -54,17 +54,17 @@ class Search extends React.PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
focus() {
|
focus() {
|
||||||
this.inputRef.focus();
|
this.inputRef.current.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div className="search">
|
<div className="search">
|
||||||
<input
|
<input
|
||||||
|
ref={this.inputRef}
|
||||||
type="search"
|
type="search"
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
onKeyDown={this.handleKeyDown}
|
onKeyDown={this.handleKeyDown}
|
||||||
ref={this.inputRef}
|
|
||||||
value={this.state.immediateValue}
|
value={this.state.immediateValue}
|
||||||
/>
|
/>
|
||||||
{this.state.immediateValue !== ''
|
{this.state.immediateValue !== ''
|
||||||
|
|
|
@ -3,21 +3,30 @@
|
||||||
const React = require('react');
|
const React = require('react');
|
||||||
const PropTypes = require('prop-types');
|
const PropTypes = require('prop-types');
|
||||||
const TagList = require('./tag-selector/tag-list');
|
const TagList = require('./tag-selector/tag-list');
|
||||||
const Input = require('./form/input');
|
|
||||||
const { Button } = require('./button');
|
const { Button } = require('./button');
|
||||||
const { IconTagSelectorMenu } = require('./icons');
|
const { IconTagSelectorMenu } = require('./icons');
|
||||||
const Search = require('./search');
|
const Search = require('./search');
|
||||||
|
|
||||||
class TagSelector extends React.Component {
|
class TagSelector extends React.PureComponent {
|
||||||
render() {
|
render() {
|
||||||
return (
|
return (
|
||||||
<div className="tag-selector">
|
<div className="tag-selector">
|
||||||
<TagList {...this.props} />
|
<TagList
|
||||||
|
ref={this.props.tagListRef}
|
||||||
|
tags={this.props.tags}
|
||||||
|
dragObserver={this.props.dragObserver}
|
||||||
|
onSelect={this.props.onSelect}
|
||||||
|
onTagContext={this.props.onTagContext}
|
||||||
|
loaded={this.props.loaded}
|
||||||
|
width={this.props.width}
|
||||||
|
height={this.props.height}
|
||||||
|
fontSize={this.props.fontSize}
|
||||||
|
/>
|
||||||
<div className="tag-selector-filter-container">
|
<div className="tag-selector-filter-container">
|
||||||
<Search
|
<Search
|
||||||
|
ref={this.props.searchBoxRef}
|
||||||
value={this.props.searchString}
|
value={this.props.searchString}
|
||||||
onSearch={this.props.onSearch}
|
onSearch={this.props.onSearch}
|
||||||
inputRef={this.searchBoxRef}
|
|
||||||
className="tag-selector-filter"
|
className="tag-selector-filter"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
|
@ -34,24 +43,34 @@ class TagSelector extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
TagSelector.propTypes = {
|
TagSelector.propTypes = {
|
||||||
|
// TagList
|
||||||
|
tagListRef: PropTypes.object,
|
||||||
tags: PropTypes.arrayOf(PropTypes.shape({
|
tags: PropTypes.arrayOf(PropTypes.shape({
|
||||||
name: PropTypes.string,
|
name: PropTypes.string,
|
||||||
selected: PropTypes.bool,
|
selected: PropTypes.bool,
|
||||||
color: PropTypes.string,
|
color: PropTypes.string,
|
||||||
disabled: PropTypes.bool
|
disabled: PropTypes.bool,
|
||||||
|
width: PropTypes.number
|
||||||
})),
|
})),
|
||||||
dragObserver: PropTypes.shape({
|
dragObserver: PropTypes.shape({
|
||||||
onDragOver: PropTypes.func,
|
onDragOver: PropTypes.func,
|
||||||
onDragExit: PropTypes.func,
|
onDragExit: PropTypes.func,
|
||||||
onDrop: PropTypes.func
|
onDrop: PropTypes.func
|
||||||
}),
|
}),
|
||||||
searchBoxRef: PropTypes.object,
|
|
||||||
searchString: PropTypes.string,
|
|
||||||
onSelect: PropTypes.func,
|
onSelect: PropTypes.func,
|
||||||
onTagContext: PropTypes.func,
|
onTagContext: PropTypes.func,
|
||||||
onSearch: PropTypes.func,
|
|
||||||
onSettings: PropTypes.func,
|
|
||||||
loaded: PropTypes.bool,
|
loaded: PropTypes.bool,
|
||||||
|
width: PropTypes.number.isRequired,
|
||||||
|
height: PropTypes.number.isRequired,
|
||||||
|
fontSize: PropTypes.number.isRequired,
|
||||||
|
|
||||||
|
// Search
|
||||||
|
searchBoxRef: PropTypes.object,
|
||||||
|
searchString: PropTypes.string,
|
||||||
|
onSearch: PropTypes.func,
|
||||||
|
|
||||||
|
// Button
|
||||||
|
onSettings: PropTypes.func,
|
||||||
};
|
};
|
||||||
|
|
||||||
TagSelector.defaultProps = {
|
TagSelector.defaultProps = {
|
||||||
|
|
|
@ -1,23 +1,123 @@
|
||||||
const React = require('react');
|
const React = require('react');
|
||||||
const { FormattedMessage } = require('react-intl');
|
|
||||||
const PropTypes = require('prop-types');
|
const PropTypes = require('prop-types');
|
||||||
const cx = require('classnames');
|
const { FormattedMessage } = require('react-intl');
|
||||||
|
var { Collection } = require('react-virtualized');
|
||||||
|
|
||||||
|
var filterBarHeight = 32;
|
||||||
|
var tagPaddingTop = 4;
|
||||||
|
var tagPaddingLeft = 2;
|
||||||
|
var tagPaddingRight = 2;
|
||||||
|
var tagPaddingBottom = 4;
|
||||||
|
var tagSpaceBetweenX = 7;
|
||||||
|
var tagSpaceBetweenY = 4;
|
||||||
|
var panePaddingTop = 2;
|
||||||
|
var panePaddingLeft = 2;
|
||||||
|
var panePaddingRight = 25;
|
||||||
|
//var panePaddingBottom = 2;
|
||||||
|
var minHorizontalPadding = panePaddingLeft + tagPaddingLeft + tagPaddingRight + panePaddingRight;
|
||||||
|
|
||||||
class TagList extends React.PureComponent {
|
class TagList extends React.PureComponent {
|
||||||
renderTag(index) {
|
constructor(props) {
|
||||||
const { tags } = this.props;
|
super(props);
|
||||||
const tag = index < tags.length ?
|
this.collectionRef = React.createRef();
|
||||||
tags[index] : {
|
this.scrollToTopOnNextUpdate = false;
|
||||||
tag: "",
|
this.prevTagCount = 0;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(prevProps) {
|
||||||
|
// Redraw all tags on every refresh
|
||||||
|
if (this.collectionRef && this.collectionRef.current) {
|
||||||
|
// If width or height changed, recompute positions. It seems like this should happen
|
||||||
|
// automatically, but it doesn't as of 9.21.0.
|
||||||
|
if (prevProps.height != this.props.height
|
||||||
|
|| prevProps.width != this.props.width
|
||||||
|
|| prevProps.fontSize != this.props.fontSize
|
||||||
|
|| prevProps.tags != this.props.tags) {
|
||||||
|
this.collectionRef.current.recomputeCellSizesAndPositions();
|
||||||
|
}
|
||||||
|
// If dimensions didn't change, just redraw at current positions. Without this, clicking
|
||||||
|
// on a tag that doesn't change the tag count (as when clicking on a second tag in an
|
||||||
|
// already filtered list) doesn't update the tag's selection state.
|
||||||
|
else {
|
||||||
|
this.collectionRef.current.forceUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.scrollToTopOnNextUpdate && this.collectionRef.current) {
|
||||||
|
this.scrollToTop();
|
||||||
|
this.scrollToTopOnNextUpdate = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollToTop() {
|
||||||
|
if (!this.collectionRef.current) return;
|
||||||
|
// Scroll to the top of the view
|
||||||
|
document.querySelector('.tag-selector-list').scrollTop = 0;
|
||||||
|
// Reset internal component scroll state to force it to redraw components, since that
|
||||||
|
// doesn't seem to happen automatically as of 9.21.0. Without this, scrolling down and
|
||||||
|
// clicking on a tag blanks out the pane (presumably because it still thinks it's at an
|
||||||
|
// offset where no tags exist).
|
||||||
|
if (this.collectionRef.current._collectionView) {
|
||||||
|
this.collectionRef.current._collectionView._setScrollPosition({
|
||||||
|
scrollLeft: 0,
|
||||||
|
scrollTop: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate the x,y coordinates of all tags
|
||||||
|
*/
|
||||||
|
updatePositions() {
|
||||||
|
var tagMaxWidth = this.props.width - minHorizontalPadding;
|
||||||
|
var rowHeight = tagPaddingTop + this.props.fontSize + tagPaddingBottom + tagSpaceBetweenY;
|
||||||
|
var positions = [];
|
||||||
|
var row = 0;
|
||||||
|
let rowX = panePaddingLeft;
|
||||||
|
for (let i = 0; i < this.props.tags.length; i++) {
|
||||||
|
let tag = this.props.tags[i];
|
||||||
|
let tagWidth = tagPaddingLeft + Math.min(tag.width, tagMaxWidth) + tagPaddingRight;
|
||||||
|
// If cell fits, add to current row
|
||||||
|
if ((rowX + tagWidth) < (this.props.width - panePaddingLeft - panePaddingRight)) {
|
||||||
|
positions[i] = [rowX, panePaddingTop + (row * rowHeight)];
|
||||||
|
}
|
||||||
|
// Otherwise, start new row
|
||||||
|
else {
|
||||||
|
row++;
|
||||||
|
rowX = panePaddingLeft;
|
||||||
|
positions[i] = [rowX, panePaddingTop + (row * rowHeight)];
|
||||||
|
}
|
||||||
|
rowX += tagWidth + tagSpaceBetweenX;
|
||||||
|
}
|
||||||
|
this.positions = positions;
|
||||||
|
}
|
||||||
|
|
||||||
|
cellSizeAndPositionGetter = ({ index }) => {
|
||||||
|
var tagMaxWidth = this.props.width - minHorizontalPadding;
|
||||||
|
return {
|
||||||
|
width: Math.min(this.props.tags[index].width, tagMaxWidth),
|
||||||
|
height: this.props.fontSize,
|
||||||
|
x: this.positions[index][0],
|
||||||
|
y: this.positions[index][1]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
renderTag = ({ index, _key, style }) => {
|
||||||
|
var tag = this.props.tags[index];
|
||||||
|
|
||||||
const { onDragOver, onDragExit, onDrop } = this.props.dragObserver;
|
const { onDragOver, onDragExit, onDrop } = this.props.dragObserver;
|
||||||
|
|
||||||
const className = cx('tag-selector-item', 'zotero-clicky', {
|
var className = 'tag-selector-item zotero-clicky';
|
||||||
selected: tag.selected,
|
if (tag.selected) {
|
||||||
colored: tag.color,
|
className += ' selected';
|
||||||
disabled: tag.disabled
|
}
|
||||||
});
|
if (tag.color) {
|
||||||
|
className += ' colored';
|
||||||
|
}
|
||||||
|
if (tag.disabled) {
|
||||||
|
className += ' disabled';
|
||||||
|
}
|
||||||
|
|
||||||
let props = {
|
let props = {
|
||||||
className,
|
className,
|
||||||
onClick: ev => !tag.disabled && this.props.onSelect(tag.name, ev),
|
onClick: ev => !tag.disabled && this.props.onSelect(tag.name, ev),
|
||||||
|
@ -26,52 +126,89 @@ class TagList extends React.PureComponent {
|
||||||
onDragExit,
|
onDragExit,
|
||||||
onDrop
|
onDrop
|
||||||
};
|
};
|
||||||
|
|
||||||
|
props.style = {
|
||||||
|
...style
|
||||||
|
};
|
||||||
|
|
||||||
if (tag.color) {
|
if (tag.color) {
|
||||||
props['style'] = {
|
props.style.color = tag.color;
|
||||||
color: tag.color,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<li key={index} {...props}>
|
<div key={tag.name} {...props}>
|
||||||
{tag.name}
|
{tag.name}
|
||||||
</li>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const totalTagCount = this.props.tags.length;
|
Zotero.debug("Rendering tag list");
|
||||||
var tagList = (
|
const tagCount = this.props.tags.length;
|
||||||
<ul className="tag-selector-list">
|
|
||||||
{
|
var tagList;
|
||||||
[...Array(totalTagCount).keys()].map(index => this.renderTag(index))
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
);
|
|
||||||
if (!this.props.loaded) {
|
if (!this.props.loaded) {
|
||||||
tagList = (
|
tagList = (
|
||||||
<div className="tag-selector-message">
|
<div className="tag-selector-message">
|
||||||
<FormattedMessage id="zotero.tagSelector.loadingTags" />
|
<FormattedMessage id="zotero.tagSelector.loadingTags" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
} else if (totalTagCount == 0) {
|
}
|
||||||
|
else if (tagCount == 0) {
|
||||||
tagList = (
|
tagList = (
|
||||||
<div className="tag-selector-message">
|
<div className="tag-selector-message">
|
||||||
<FormattedMessage id="zotero.tagSelector.noTagsToDisplay" />
|
<FormattedMessage id="zotero.tagSelector.noTagsToDisplay" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
// Scroll to top if more than one tag was removed
|
||||||
|
if (tagCount < this.prevTagCount - 1) {
|
||||||
|
this.scrollToTopOnNextUpdate = true;
|
||||||
|
}
|
||||||
|
this.prevTagCount = tagCount;
|
||||||
|
this.updatePositions();
|
||||||
|
tagList = (
|
||||||
|
<Collection
|
||||||
|
ref={this.collectionRef}
|
||||||
|
className="tag-selector-list"
|
||||||
|
cellCount={tagCount}
|
||||||
|
cellRenderer={this.renderTag}
|
||||||
|
cellSizeAndPositionGetter={this.cellSizeAndPositionGetter}
|
||||||
|
verticalOverscanSize={300}
|
||||||
|
width={this.props.width}
|
||||||
|
height={this.props.height - filterBarHeight}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="tag-selector-list-container">
|
||||||
className="tag-selector-container"
|
|
||||||
ref={ref => { this.container = ref }}>
|
|
||||||
{tagList}
|
{tagList}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
tags: PropTypes.arrayOf(PropTypes.shape({
|
||||||
|
name: PropTypes.string,
|
||||||
|
selected: PropTypes.bool,
|
||||||
|
color: PropTypes.string,
|
||||||
|
disabled: PropTypes.bool,
|
||||||
|
width: PropTypes.number
|
||||||
|
})),
|
||||||
|
dragObserver: PropTypes.shape({
|
||||||
|
onDragOver: PropTypes.func,
|
||||||
|
onDragExit: PropTypes.func,
|
||||||
|
onDrop: PropTypes.func
|
||||||
|
}),
|
||||||
|
onSelect: PropTypes.func,
|
||||||
|
onTagContext: PropTypes.func,
|
||||||
|
loaded: PropTypes.bool,
|
||||||
|
width: PropTypes.number.isRequired,
|
||||||
|
height: PropTypes.number.isRequired,
|
||||||
|
fontSize: PropTypes.number.isRequired,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = TagList;
|
module.exports = TagList;
|
||||||
|
|
|
@ -1,24 +1,22 @@
|
||||||
/* global Zotero: false */
|
/* global Zotero: false */
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
(function() {
|
|
||||||
|
|
||||||
const React = require('react');
|
const React = require('react');
|
||||||
const ReactDOM = require('react-dom');
|
const ReactDOM = require('react-dom');
|
||||||
|
const PropTypes = require('prop-types');
|
||||||
const { IntlProvider } = require('react-intl');
|
const { IntlProvider } = require('react-intl');
|
||||||
const TagSelector = require('components/tag-selector.js');
|
const TagSelector = require('components/tag-selector.js');
|
||||||
const noop = Promise.resolve();
|
|
||||||
const defaults = {
|
const defaults = {
|
||||||
tagColors: new Map(),
|
tagColors: new Map(),
|
||||||
tags: [],
|
tags: [],
|
||||||
|
scope: null,
|
||||||
showAutomatic: Zotero.Prefs.get('tagSelector.showAutomatic'),
|
showAutomatic: Zotero.Prefs.get('tagSelector.showAutomatic'),
|
||||||
searchString: '',
|
searchString: '',
|
||||||
inScope: new Set(),
|
|
||||||
loaded: false
|
loaded: false
|
||||||
};
|
};
|
||||||
const { Cc, Ci } = require('chrome');
|
const { Cc, Ci } = require('chrome');
|
||||||
|
|
||||||
Zotero.TagSelector = class TagSelectorContainer extends React.Component {
|
Zotero.TagSelector = class TagSelectorContainer extends React.PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
this._notifierID = Zotero.Notifier.registerObserver(
|
this._notifierID = Zotero.Notifier.registerObserver(
|
||||||
|
@ -26,31 +24,57 @@ Zotero.TagSelector = class TagSelectorContainer extends React.Component {
|
||||||
['collection-item', 'item', 'item-tag', 'tag', 'setting'],
|
['collection-item', 'item', 'item-tag', 'tag', 'setting'],
|
||||||
'tagSelector'
|
'tagSelector'
|
||||||
);
|
);
|
||||||
this.displayAllTags = Zotero.Prefs.get('tagSelector.displayAllTags');
|
Zotero.Prefs.registerObserver('fontSize', this.handleFontChange.bind(this));
|
||||||
this.selectedTags = new Set();
|
|
||||||
this.state = defaults;
|
this.tagListRef = React.createRef();
|
||||||
this.searchBoxRef = React.createRef();
|
this.searchBoxRef = React.createRef();
|
||||||
|
|
||||||
|
this.displayAllTags = Zotero.Prefs.get('tagSelector.displayAllTags');
|
||||||
|
// Not stored in state to avoid an unnecessary refresh. Instead, when a tag is selected, we
|
||||||
|
// trigger the selection handler, which updates the visible items, which triggers
|
||||||
|
// onItemViewChanged(), which triggers a refresh with the new tags.
|
||||||
|
this.selectedTags = new Set();
|
||||||
|
this.widths = new Map();
|
||||||
|
this.widthsBold = new Map();
|
||||||
|
|
||||||
|
this.state = {
|
||||||
|
...defaults,
|
||||||
|
...this.getContainerDimensions(),
|
||||||
|
...this.getFontInfo()
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
focusTextbox() {
|
focusTextbox() {
|
||||||
this.searchBoxRef.focus();
|
this.searchBoxRef.current.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
componentDidUpdate(_prevProps, _prevState) {
|
||||||
|
Zotero.debug("Tag selector updated");
|
||||||
|
|
||||||
|
// If we changed collections, scroll to top
|
||||||
|
if (this.collectionTreeRow && this.collectionTreeRow.id != this.prevTreeViewID) {
|
||||||
|
this.tagListRef.current.scrollToTop();
|
||||||
|
this.prevTreeViewID = this.collectionTreeRow.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update trigger #1 (triggered by ZoteroPane)
|
// Update trigger #1 (triggered by ZoteroPane)
|
||||||
async onItemViewChanged({collectionTreeRow, libraryID, tagsInScope}) {
|
async onItemViewChanged({ collectionTreeRow, libraryID }) {
|
||||||
Zotero.debug('Updating tag selector from current view');
|
Zotero.debug('Updating tag selector from current view');
|
||||||
|
|
||||||
this.collectionTreeRow = collectionTreeRow || this.collectionTreeRow;
|
var prevLibraryID = this.libraryID;
|
||||||
|
this.collectionTreeRow = collectionTreeRow;
|
||||||
let newState = {loaded: true};
|
|
||||||
|
|
||||||
if (!this.state.tagColors.length && libraryID && this.libraryID != libraryID) {
|
|
||||||
newState.tagColors = Zotero.Tags.getColors(libraryID);
|
|
||||||
}
|
|
||||||
this.libraryID = libraryID;
|
this.libraryID = libraryID;
|
||||||
|
|
||||||
newState.tags = await this.getTags(tagsInScope,
|
var newState = {
|
||||||
this.state.tagColors.length ? this.state.tagColors : newState.tagColors);
|
loaded: true
|
||||||
|
};
|
||||||
|
if (prevLibraryID != libraryID) {
|
||||||
|
newState.tagColors = Zotero.Tags.getColors(libraryID);
|
||||||
|
}
|
||||||
|
var { tags, scope } = await this.getTagsAndScope();
|
||||||
|
newState.tags = tags;
|
||||||
|
newState.scope = scope;
|
||||||
this.setState(newState);
|
this.setState(newState);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,13 +83,13 @@ Zotero.TagSelector = class TagSelectorContainer extends React.Component {
|
||||||
if (type === 'setting') {
|
if (type === 'setting') {
|
||||||
if (ids.some(val => val.split('/')[1] == 'tagColors')) {
|
if (ids.some(val => val.split('/')[1] == 'tagColors')) {
|
||||||
Zotero.debug("Updating tag selector after tag color change");
|
Zotero.debug("Updating tag selector after tag color change");
|
||||||
let tagColors = Zotero.Tags.getColors(this.libraryID);
|
this.setState({
|
||||||
this.state.tagColors = tagColors;
|
tagColors: Zotero.Tags.getColors(this.libraryID)
|
||||||
this.setState({tagColors, tags: await this.getTags(null, tagColors)});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ignore anything other than deletes in duplicates view
|
// Ignore anything other than deletes in duplicates view
|
||||||
if (this.collectionTreeRow && this.collectionTreeRow.isDuplicates()) {
|
if (this.collectionTreeRow && this.collectionTreeRow.isDuplicates()) {
|
||||||
switch (event) {
|
switch (event) {
|
||||||
|
@ -83,107 +107,346 @@ Zotero.TagSelector = class TagSelectorContainer extends React.Component {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If a selected tag no longer exists, deselect it
|
// Ignore tag deletions, which are handled by 'item-tag' 'remove'
|
||||||
if (type == 'tag' && (event == 'modify' || event == 'delete')) {
|
if (type == 'tag') {
|
||||||
let changed = false;
|
|
||||||
for (let id of ids) {
|
|
||||||
let tag = extraData[id].old.tag;
|
|
||||||
if (this.selectedTags.has(tag)) {
|
|
||||||
this.selectedTags.delete(tag);
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (changed && typeof(this.props.onSelection) === 'function') {
|
|
||||||
this.props.onSelection(this.selectedTags);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Check libraryID for some events to avoid refreshing unnecessarily on sync changes?
|
|
||||||
|
|
||||||
Zotero.debug("Updating tag selector after tag change");
|
Zotero.debug("Updating tag selector after tag change");
|
||||||
var newTags = await this.getTags();
|
|
||||||
|
|
||||||
if (type == 'item-tag' && event == 'remove') {
|
if (type == 'item-tag' && ['add', 'remove'].includes(event)) {
|
||||||
let changed = false;
|
let changedTagsInScope = [];
|
||||||
let visibleTags = newTags.map(tag => tag.tag);
|
let changedTagsInView = [];
|
||||||
|
// Group tags by tag type for lookup
|
||||||
|
let tagsByType = new Map();
|
||||||
for (let id of ids) {
|
for (let id of ids) {
|
||||||
let tag = extraData[id].tag;
|
let [_, tagID] = id.split('-');
|
||||||
if (this.selectedTags.has(tag) && !visibleTags.includes(tag)) {
|
let type = extraData[id].type;
|
||||||
this.selectedTags.delete(tag);
|
let typeTags = tagsByType.get(type);
|
||||||
changed = true;
|
if (!typeTags) {
|
||||||
|
typeTags = [];
|
||||||
|
tagsByType.set(type, typeTags);
|
||||||
|
}
|
||||||
|
typeTags.push(parseInt(tagID));
|
||||||
|
}
|
||||||
|
// Check tags for each tag type to see if they're in view/scope
|
||||||
|
for (let [type, tagIDs] of tagsByType) {
|
||||||
|
changedTagsInScope.push(...await this.collectionTreeRow.getTags([type], tagIDs));
|
||||||
|
if (this.displayAllTags) {
|
||||||
|
changedTagsInView.push(
|
||||||
|
...await Zotero.Tags.getAllWithin({ libraryID: this.libraryID, tagIDs })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (changed && typeof(this.props.onSelection) === 'function') {
|
if (!this.displayAllTags) {
|
||||||
this.props.onSelection(this.selectedTags);
|
changedTagsInView = changedTagsInScope;
|
||||||
|
}
|
||||||
|
changedTagsInScope = new Set(changedTagsInScope.map(tag => tag.tag));
|
||||||
|
|
||||||
|
if (event == 'add') {
|
||||||
|
this.sortTags(changedTagsInView);
|
||||||
|
if (!changedTagsInView.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.setState((state, _props) => {
|
||||||
|
// Insert sorted
|
||||||
|
var newTags = [...state.tags];
|
||||||
|
var newScope = state.scope ? new Set(state.scope) : new Set();
|
||||||
|
var scopeChanged = false;
|
||||||
|
var start = 0;
|
||||||
|
var collation = Zotero.getLocaleCollation();
|
||||||
|
for (let tag of changedTagsInView) {
|
||||||
|
let name = tag.tag;
|
||||||
|
let added = false;
|
||||||
|
for (let i = start; i < newTags.length; i++) {
|
||||||
|
start++;
|
||||||
|
let cmp = collation.compareString(1, newTags[i].tag, name);
|
||||||
|
// Skip tag if it already exists
|
||||||
|
if (cmp == 0) {
|
||||||
|
added = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (cmp > 0) {
|
||||||
|
newTags.splice(i, 0, tag);
|
||||||
|
added = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!added) {
|
||||||
|
newTags.push(tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedTagsInScope.has(name) && !newScope.has(name)) {
|
||||||
|
newScope.add(name);
|
||||||
|
scopeChanged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var newState = {
|
||||||
|
tags: newTags
|
||||||
|
};
|
||||||
|
if (scopeChanged) {
|
||||||
|
newState.scope = newScope;
|
||||||
|
}
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else if (event == 'remove') {
|
||||||
|
changedTagsInView = new Set(changedTagsInView.map(tag => tag.tag));
|
||||||
|
|
||||||
|
this.setState((state, props) => {
|
||||||
|
var previousTags = new Set(state.tags.map(tag => tag.tag));
|
||||||
|
var tagsToRemove = new Set();
|
||||||
|
var newScope;
|
||||||
|
var selectionChanged = false;
|
||||||
|
for (let id of ids) {
|
||||||
|
let name = extraData[id].tag;
|
||||||
|
let removed = false;
|
||||||
|
|
||||||
|
// If tag was shown previously and shouldn't be anymore, remove from view
|
||||||
|
if (previousTags.has(name) && !changedTagsInView.has(name)) {
|
||||||
|
tagsToRemove.add(name);
|
||||||
|
removed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from scope if there is one
|
||||||
|
if (state.scope && state.scope.has(name) && !changedTagsInScope.has(name)) {
|
||||||
|
if (!newScope) {
|
||||||
|
newScope = new Set(state.scope);
|
||||||
|
}
|
||||||
|
newScope.delete(name);
|
||||||
|
removed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removed from either view or scope
|
||||||
|
if (removed) {
|
||||||
|
// Deselect if selected
|
||||||
|
if (this.selectedTags.has(name)) {
|
||||||
|
this.selectedTags.delete(name);
|
||||||
|
selectionChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If removing a tag from view, clear its cached width. It might still
|
||||||
|
// be in this or another library, but if so we'll just recalculate its
|
||||||
|
// width the next time it's needed.
|
||||||
|
this.widths.delete(name);
|
||||||
|
this.widthsBold.delete(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (selectionChanged && typeof props.onSelection == 'function') {
|
||||||
|
props.onSelection(this.selectedTags);
|
||||||
|
}
|
||||||
|
var newState = {};
|
||||||
|
if (tagsToRemove.size) {
|
||||||
|
newState.tags = state.tags.filter(tag => !tagsToRemove.has(tag.tag));
|
||||||
|
}
|
||||||
|
if (newScope) {
|
||||||
|
newState.scope = newScope;
|
||||||
|
}
|
||||||
|
return newState;
|
||||||
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.setState({tags: newTags});
|
this.setState(await this.getTagsAndScope());
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTags(tagsInScope, tagColors) {
|
async getTagsAndScope() {
|
||||||
if (!tagsInScope) {
|
var tags = await this.collectionTreeRow.getTags();
|
||||||
tagsInScope = await this.collectionTreeRow.getChildTags();
|
// The scope is all visible tags, not all tags in the library
|
||||||
}
|
var scope = new Set(tags.map(t => t.tag));
|
||||||
this.inScope = new Set(tagsInScope.map(t => t.tag));
|
|
||||||
let tags;
|
|
||||||
if (this.displayAllTags) {
|
if (this.displayAllTags) {
|
||||||
tags = await Zotero.Tags.getAll(this.libraryID, [0, 1]);
|
tags = await Zotero.Tags.getAll(this.libraryID);
|
||||||
} else {
|
|
||||||
tags = tagsInScope
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tagColors = tagColors || this.state.tagColors;
|
// If tags haven't changed, return previous array without sorting again
|
||||||
|
if (this.state.tags.length == tags.length) {
|
||||||
// Add colored tags that aren't already real tags
|
let prevTags = new Set(this.state.tags.map(tag => tag.tag));
|
||||||
let regularTags = new Set(tags.map(tag => tag.tag));
|
let same = true;
|
||||||
let coloredTags = Array.from(tagColors.keys());
|
for (let tag of tags) {
|
||||||
|
if (!prevTags.has(tag.tag)) {
|
||||||
coloredTags.filter(ct => !regularTags.has(ct)).forEach(x =>
|
same = false;
|
||||||
tags.push(Zotero.Tags.cleanData({ tag: x }))
|
break;
|
||||||
);
|
}
|
||||||
|
|
||||||
// Sort by name (except for colored tags, which sort by assigned number key)
|
|
||||||
tags.sort(function (a, b) {
|
|
||||||
let aColored = tagColors.get(a.tag);
|
|
||||||
let bColored = tagColors.get(b.tag);
|
|
||||||
if (aColored && !bColored) return -1;
|
|
||||||
if (!aColored && bColored) return 1;
|
|
||||||
if (aColored && bColored) {
|
|
||||||
return aColored.position - bColored.position;
|
|
||||||
}
|
}
|
||||||
|
if (same) {
|
||||||
return Zotero.getLocaleCollation().compareString(1, a.tag, b.tag);
|
Zotero.debug("Tags haven't changed");
|
||||||
});
|
return {
|
||||||
|
tags: this.state.tags,
|
||||||
|
scope
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return tags;
|
this.sortTags(tags);
|
||||||
|
return { tags, scope };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sortTags(tags) {
|
||||||
|
var d = new Date();
|
||||||
|
var collation = Zotero.Intl.collation;
|
||||||
|
tags.sort(function (a, b) {
|
||||||
|
return collation.compareString(1, a.tag, b.tag);
|
||||||
|
});
|
||||||
|
Zotero.debug(`Sorted tags in ${new Date() - d} ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getContainerDimensions() {
|
||||||
|
var container = document.getElementById(this.props.container);
|
||||||
|
return {
|
||||||
|
width: container.clientWidth,
|
||||||
|
height: container.clientHeight
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
handleResize() {
|
||||||
|
//Zotero.debug("Resizing tag selector");
|
||||||
|
var { width, height } = this.getContainerDimensions();
|
||||||
|
this.setState({ width, height });
|
||||||
|
}
|
||||||
|
|
||||||
|
getFontInfo() {
|
||||||
|
var elem = document.createElementNS("http://www.w3.org/1999/xhtml", "div");
|
||||||
|
elem.className = 'tag-selector-item';
|
||||||
|
elem.style.position = 'absolute';
|
||||||
|
elem.style.opacity = 0;
|
||||||
|
var container = document.getElementById(this.props.container);
|
||||||
|
container.appendChild(elem);
|
||||||
|
var style = window.getComputedStyle(elem);
|
||||||
|
var props = {
|
||||||
|
fontSize: style.getPropertyValue('font-size'),
|
||||||
|
fontFamily: style.getPropertyValue('font-family')
|
||||||
|
};
|
||||||
|
container.removeChild(elem);
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recompute tag widths based on the current font settings
|
||||||
|
*/
|
||||||
|
handleFontChange() {
|
||||||
|
this.widths.clear();
|
||||||
|
this.widthsBold.clear();
|
||||||
|
this.setState({
|
||||||
|
...this.getFontInfo()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses canvas.measureText to compute and return the width of the given text of given font in pixels.
|
||||||
|
*
|
||||||
|
* @param {String} text The text to be rendered.
|
||||||
|
* @param {String} font The css font descriptor that text is to be rendered with (e.g. "bold 14px verdana").
|
||||||
|
*
|
||||||
|
* @see https://stackoverflow.com/questions/118241/calculate-text-width-with-javascript/21015393#21015393
|
||||||
|
*/
|
||||||
|
getTextWidth(text, font) {
|
||||||
|
// re-use canvas object for better performance
|
||||||
|
var canvas = this.canvas || (this.canvas = document.createElementNS("http://www.w3.org/1999/xhtml", "canvas"));
|
||||||
|
var context = canvas.getContext("2d");
|
||||||
|
context.font = font;
|
||||||
|
// Add a little more to make sure we don't crop
|
||||||
|
var metrics = context.measureText(text);
|
||||||
|
return Math.ceil(metrics.width);
|
||||||
|
}
|
||||||
|
|
||||||
|
getWidth(name) {
|
||||||
|
var num = 0;
|
||||||
|
var font = this.state.fontSize + ' ' + this.state.fontFamily;
|
||||||
|
// Colored tags are shown in bold, which results in a different width
|
||||||
|
var fontBold = 'bold ' + font;
|
||||||
|
let hasColor = this.state.tagColors.has(name);
|
||||||
|
let widths = hasColor ? this.widthsBold : this.widths;
|
||||||
|
let width = widths.get(name);
|
||||||
|
if (width === undefined) {
|
||||||
|
//Zotero.debug(`Calculating ${hasColor ? 'bold ' : ''}width for tag '${name}'`);
|
||||||
|
width = this.getTextWidth(name, hasColor ? fontBold : font);
|
||||||
|
widths.set(name, width);
|
||||||
|
}
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
let tags = this.state.tags;
|
Zotero.debug("Rendering tag selector");
|
||||||
|
var tags = this.state.tags;
|
||||||
|
var tagColors = this.state.tagColors;
|
||||||
|
|
||||||
if (!this.state.showAutomatic) {
|
if (!this.state.showAutomatic) {
|
||||||
tags = tags.filter(t => t.type != 1).map(t => t.tag);
|
tags = tags.filter(t => t.type != 1);
|
||||||
}
|
}
|
||||||
// Remove duplicates from auto and manual tags
|
// Remove duplicates from auto and manual tags
|
||||||
else {
|
else {
|
||||||
tags = Array.from(new Set(tags.map(t => t.tag)));
|
let seen = new Set();
|
||||||
|
let newTags = [];
|
||||||
|
for (let tag of tags) {
|
||||||
|
if (!seen.has(tag.tag)) {
|
||||||
|
newTags.push(tag);
|
||||||
|
seen.add(tag.tag);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tags = newTags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Extract colored tags
|
||||||
|
var coloredTags = [];
|
||||||
|
for (let i = 0; i < tags.length; i++) {
|
||||||
|
if (tagColors.has(tags[i].tag)) {
|
||||||
|
coloredTags.push(...tags.splice(i, 1));
|
||||||
|
i--;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add colored tags that aren't already real tags
|
||||||
|
var extractedColoredTags = new Set(coloredTags.map(tag => tag.tag));
|
||||||
|
[...tagColors.keys()]
|
||||||
|
.filter(tag => !extractedColoredTags.has(tag))
|
||||||
|
.forEach(tag => coloredTags.push(Zotero.Tags.cleanData({ tag })));
|
||||||
|
|
||||||
|
// Sort colored tags and place at beginning
|
||||||
|
coloredTags.sort((a, b) => {
|
||||||
|
return tagColors.get(a.tag).position - tagColors.get(b.tag).position;
|
||||||
|
});
|
||||||
|
tags = coloredTags.concat(tags);
|
||||||
|
|
||||||
|
// Filter
|
||||||
if (this.state.searchString) {
|
if (this.state.searchString) {
|
||||||
let lcStr = this.state.searchString.toLowerCase();
|
let lcStr = this.state.searchString.toLowerCase();
|
||||||
tags = tags.filter(tag => tag.toLowerCase().includes(lcStr));
|
tags = tags.filter(tag => tag.tag.toLowerCase().includes(lcStr));
|
||||||
}
|
}
|
||||||
tags = tags.map((name) => {
|
|
||||||
return {
|
// Prepare tag objects for list component
|
||||||
|
//var d = new Date();
|
||||||
|
var inTagColors = true;
|
||||||
|
tags = tags.map((tag) => {
|
||||||
|
let name = tag.tag;
|
||||||
|
tag = {
|
||||||
name,
|
name,
|
||||||
selected: this.selectedTags.has(name),
|
width: tag.width
|
||||||
color: this.state.tagColors.has(name) ? this.state.tagColors.get(name).color : '',
|
};
|
||||||
disabled: !this.inScope.has(name)
|
if (this.selectedTags.has(name)) {
|
||||||
|
tag.selected = true;
|
||||||
}
|
}
|
||||||
});
|
if (inTagColors && tagColors.has(name)) {
|
||||||
|
tag.color = tagColors.get(name).color;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
inTagColors = false;
|
||||||
|
}
|
||||||
|
// If we're not displaying all tags, we only need to check the scope for colored tags,
|
||||||
|
// since everything else will be in scope
|
||||||
|
if ((this.displayAllTags || inTagColors) && !this.state.scope.has(name)) {
|
||||||
|
tag.disabled = true;
|
||||||
|
}
|
||||||
|
tag.width = this.getWidth(name);
|
||||||
|
return tag;
|
||||||
|
});
|
||||||
|
//Zotero.debug(`Prepared tags in ${new Date() - d} ms`);
|
||||||
return <TagSelector
|
return <TagSelector
|
||||||
tags={tags}
|
tags={tags}
|
||||||
searchBoxRef={this.searchBoxRef}
|
searchBoxRef={this.searchBoxRef}
|
||||||
|
tagListRef={this.tagListRef}
|
||||||
searchString={this.state.searchString}
|
searchString={this.state.searchString}
|
||||||
dragObserver={this.dragObserver}
|
dragObserver={this.dragObserver}
|
||||||
onSelect={this.state.viewOnly ? () => {} : this.handleTagSelected}
|
onSelect={this.state.viewOnly ? () => {} : this.handleTagSelected}
|
||||||
|
@ -191,6 +454,9 @@ Zotero.TagSelector = class TagSelectorContainer extends React.Component {
|
||||||
onSearch={this.handleSearch}
|
onSearch={this.handleSearch}
|
||||||
onSettings={this.handleSettings.bind(this)}
|
onSettings={this.handleSettings.bind(this)}
|
||||||
loaded={this.state.loaded}
|
loaded={this.state.loaded}
|
||||||
|
width={this.state.width}
|
||||||
|
height={this.state.height}
|
||||||
|
fontSize={parseInt(this.state.fontSize.replace('px', ''))}
|
||||||
/>;
|
/>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -198,13 +464,6 @@ Zotero.TagSelector = class TagSelectorContainer extends React.Component {
|
||||||
this.state.viewOnly != (mode == 'view') && this.setState({viewOnly: mode == 'view'});
|
this.state.viewOnly != (mode == 'view') && this.setState({viewOnly: mode == 'view'});
|
||||||
}
|
}
|
||||||
|
|
||||||
uninit() {
|
|
||||||
ReactDOM.unmountComponentAtNode(this.domEl);
|
|
||||||
if (this._notifierID) {
|
|
||||||
Zotero.Notifier.unregisterObserver(this._notifierID);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTagContext = (tag, ev) => {
|
handleTagContext = (tag, ev) => {
|
||||||
let tagContextMenu = document.getElementById('tag-menu');
|
let tagContextMenu = document.getElementById('tag-menu');
|
||||||
ev.preventDefault();
|
ev.preventDefault();
|
||||||
|
@ -244,7 +503,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.Component {
|
||||||
var elem = event.target;
|
var elem = event.target;
|
||||||
|
|
||||||
// Ignore drops not on tags
|
// Ignore drops not on tags
|
||||||
if (elem.localName != 'li') {
|
if (!elem.classList.contains('tag-selector-item')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -262,7 +521,7 @@ Zotero.TagSelector = class TagSelectorContainer extends React.Component {
|
||||||
var elem = event.target;
|
var elem = event.target;
|
||||||
|
|
||||||
// Ignore drops not on tags
|
// Ignore drops not on tags
|
||||||
if (elem.localName != 'li') {
|
if (!elem.classList.contains('tag-selector-item')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -346,8 +605,8 @@ Zotero.TagSelector = class TagSelectorContainer extends React.Component {
|
||||||
|
|
||||||
let selectedTags = this.selectedTags;
|
let selectedTags = this.selectedTags;
|
||||||
if (selectedTags.has(this.contextTag.name)) {
|
if (selectedTags.has(this.contextTag.name)) {
|
||||||
var wasSelected = true;
|
|
||||||
selectedTags.delete(this.contextTag.name);
|
selectedTags.delete(this.contextTag.name);
|
||||||
|
selectedTags.add(newName.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Zotero.Tags.getID(this.contextTag.name)) {
|
if (Zotero.Tags.getID(this.contextTag.name)) {
|
||||||
|
@ -363,11 +622,6 @@ Zotero.TagSelector = class TagSelectorContainer extends React.Component {
|
||||||
await Zotero.Tags.setColor(this.libraryID, this.contextTag.name, false);
|
await Zotero.Tags.setColor(this.libraryID, this.contextTag.name, false);
|
||||||
await Zotero.Tags.setColor(this.libraryID, newName.value, color.color);
|
await Zotero.Tags.setColor(this.libraryID, newName.value, color.color);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (wasSelected) {
|
|
||||||
selectedTags.add(newName.value);
|
|
||||||
}
|
|
||||||
this.setState({tags: await this.getTags()})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async openDeletePrompt() {
|
async openDeletePrompt() {
|
||||||
|
@ -391,15 +645,13 @@ Zotero.TagSelector = class TagSelectorContainer extends React.Component {
|
||||||
else {
|
else {
|
||||||
await Zotero.Tags.setColor(this.libraryID, this.contextTag.name, false);
|
await Zotero.Tags.setColor(this.libraryID, this.contextTag.name, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.setState({tags: await this.getTags()});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async toggleDisplayAllTags(newValue) {
|
async toggleDisplayAllTags(newValue) {
|
||||||
newValue = typeof(newValue) === 'undefined' ? !this.displayAllTags : newValue;
|
newValue = typeof(newValue) === 'undefined' ? !this.displayAllTags : newValue;
|
||||||
Zotero.Prefs.set('tagSelector.displayAllTags', newValue);
|
Zotero.Prefs.set('tagSelector.displayAllTags', newValue);
|
||||||
this.displayAllTags = newValue;
|
this.displayAllTags = newValue;
|
||||||
this.setState({tags: await this.getTags()});
|
this.setState(await this.getTagsAndScope());
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleShowAutomatic(newValue) {
|
toggleShowAutomatic(newValue) {
|
||||||
|
@ -474,5 +726,19 @@ Zotero.TagSelector = class TagSelectorContainer extends React.Component {
|
||||||
ref.domEl = domEl;
|
ref.domEl = domEl;
|
||||||
return ref;
|
return ref;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
})();
|
uninit() {
|
||||||
|
ReactDOM.unmountComponentAtNode(this.domEl);
|
||||||
|
if (this._notifierID) {
|
||||||
|
Zotero.Notifier.unregisterObserver(this._notifierID);
|
||||||
|
}
|
||||||
|
if (this._prefObserverID) {
|
||||||
|
Zotero.Prefs.unregisterObserver('fontSize', this.handleFontChange.bind(this));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static propTypes = {
|
||||||
|
container: PropTypes.string.isRequired,
|
||||||
|
onSelection: PropTypes.func.isRequired,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
|
@ -366,13 +366,17 @@ Zotero.CollectionTreeRow.prototype.getSearchObject = Zotero.Promise.coroutine(fu
|
||||||
return s2;
|
return s2;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Zotero.CollectionTreeRow.prototype.getChildTags = function () {
|
||||||
|
Zotero.warn("Zotero.CollectionTreeRow::getChildTags() is deprecated -- use getTags() instead");
|
||||||
|
return this.getTags();
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns all the tags used by items in the current view
|
* Returns all the tags used by items in the current view
|
||||||
*
|
*
|
||||||
* @return {Promise<Object[]>}
|
* @return {Promise<Object[]>}
|
||||||
*/
|
*/
|
||||||
Zotero.CollectionTreeRow.prototype.getChildTags = Zotero.Promise.coroutine(function* () {
|
Zotero.CollectionTreeRow.prototype.getTags = async function (types, tagIDs) {
|
||||||
switch (this.type) {
|
switch (this.type) {
|
||||||
// TODO: implement?
|
// TODO: implement?
|
||||||
case 'share':
|
case 'share':
|
||||||
|
@ -381,9 +385,9 @@ Zotero.CollectionTreeRow.prototype.getChildTags = Zotero.Promise.coroutine(funct
|
||||||
case 'bucket':
|
case 'bucket':
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
var results = yield this.getSearchResults(true);
|
var results = await this.getSearchResults(true);
|
||||||
return Zotero.Tags.getAllWithinSearchResults(results);
|
return Zotero.Tags.getAllWithin({ tmpTable: results, types, tagIDs });
|
||||||
});
|
};
|
||||||
|
|
||||||
|
|
||||||
Zotero.CollectionTreeRow.prototype.setSearch = function (searchText) {
|
Zotero.CollectionTreeRow.prototype.setSearch = function (searchText) {
|
||||||
|
|
|
@ -1794,12 +1794,14 @@ Zotero.Item.prototype._saveData = Zotero.Promise.coroutine(function* (env) {
|
||||||
for (let i=0; i<toRemove.length; i++) {
|
for (let i=0; i<toRemove.length; i++) {
|
||||||
let tag = toRemove[i];
|
let tag = toRemove[i];
|
||||||
let tagID = Zotero.Tags.getID(tag.tag);
|
let tagID = Zotero.Tags.getID(tag.tag);
|
||||||
|
let tagType = tag.type ? tag.type : 0;
|
||||||
let sql = "DELETE FROM itemTags WHERE itemID=? AND tagID=? AND type=?";
|
let sql = "DELETE FROM itemTags WHERE itemID=? AND tagID=? AND type=?";
|
||||||
yield Zotero.DB.queryAsync(sql, [this.id, tagID, tag.type ? tag.type : 0]);
|
yield Zotero.DB.queryAsync(sql, [this.id, tagID, tagType]);
|
||||||
let notifierData = {};
|
let notifierData = {};
|
||||||
notifierData[this.id + '-' + tagID] = {
|
notifierData[this.id + '-' + tagID] = {
|
||||||
libraryID: this.libraryID,
|
libraryID: this.libraryID,
|
||||||
tag: tag.tag
|
tag: tag.tag,
|
||||||
|
type: tagType
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!env.options.skipNotifier) {
|
if (!env.options.skipNotifier) {
|
||||||
|
|
|
@ -137,39 +137,63 @@ Zotero.Tags = new function() {
|
||||||
* Get all tags in library
|
* Get all tags in library
|
||||||
*
|
*
|
||||||
* @param {Number} libraryID
|
* @param {Number} libraryID
|
||||||
* @param {Array} [types] Tag types to fetch
|
* @param {Number[]} [types] - Tag types to fetch
|
||||||
* @return {Promise<Array>} A promise for an array containing tag objects in API JSON format
|
* @return {Promise<Array>} A promise for an array containing tag objects in API JSON format
|
||||||
* [{ { tag: "foo" }, { tag: "bar", type: 1 }]
|
* [{ { tag: "foo" }, { tag: "bar", type: 1 }]
|
||||||
*/
|
*/
|
||||||
this.getAll = Zotero.Promise.coroutine(function* (libraryID, types) {
|
this.getAll = async function (libraryID, types) {
|
||||||
var sql = "SELECT DISTINCT name AS tag, type FROM tags "
|
return this.getAllWithin({ libraryID, types });
|
||||||
+ "JOIN itemTags USING (tagID) JOIN items USING (itemID) WHERE libraryID=?";
|
};
|
||||||
var params = [libraryID];
|
|
||||||
if (types) {
|
|
||||||
sql += " AND type IN (" + types.join() + ")";
|
|
||||||
}
|
|
||||||
var rows = yield Zotero.DB.queryAsync(sql, params);
|
|
||||||
return rows.map((row) => this.cleanData(row));
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all tags within the items of a temporary table of search results
|
* Get all tags within the items of a temporary table of search results
|
||||||
*
|
*
|
||||||
* @param {String} tmpTable Temporary table with items to use
|
* @param {Object}
|
||||||
* @param {Array} [types] Array of tag types to fetch
|
* @param {Object.Number} libraryID
|
||||||
* @return {Promise<Object>} Promise for object with tag data in API JSON format, keyed by tagID
|
* @param {Object.String} tmpTable - Temporary table with items to use
|
||||||
|
* @param {Object.Number[]} [types] - Array of tag types to fetch
|
||||||
|
* @param {Object.Number[]} [tagIDs] - Array of tagIDs to limit the result to
|
||||||
|
* @return {Promise<Array[]>} - Promise for an array of tag objects in API JSON format
|
||||||
*/
|
*/
|
||||||
this.getAllWithinSearchResults = Zotero.Promise.coroutine(function* (tmpTable, types) {
|
this.getAllWithin = async function ({ libraryID, tmpTable, types, tagIDs }) {
|
||||||
var sql = "SELECT DISTINCT name AS tag, type FROM itemTags "
|
// mozStorage/Proxy are slow, so get in a single column
|
||||||
+ "JOIN tags USING (tagID) WHERE itemID IN "
|
var sql = "SELECT DISTINCT tagID || ':' || type FROM itemTags "
|
||||||
+ "(SELECT itemID FROM " + tmpTable + ") ";
|
+ "JOIN tags USING (tagID) ";
|
||||||
if (types) {
|
var params = [];
|
||||||
sql += "AND type IN (" + types.join() + ") ";
|
if (libraryID) {
|
||||||
|
sql += "JOIN items USING (itemID) WHERE libraryID = ? ";
|
||||||
|
params.push(libraryID);
|
||||||
}
|
}
|
||||||
var rows = yield Zotero.DB.queryAsync(sql);
|
else {
|
||||||
return rows.map((row) => this.cleanData(row));
|
sql += "WHERE 1 ";
|
||||||
});
|
}
|
||||||
|
if (tmpTable) {
|
||||||
|
if (libraryID) {
|
||||||
|
throw new Error("tmpTable and libraryID are mutually exclusive");
|
||||||
|
}
|
||||||
|
sql += "AND itemID IN (SELECT itemID FROM " + tmpTable + ") ";
|
||||||
|
}
|
||||||
|
if (types && types.length) {
|
||||||
|
sql += "AND type IN (" + new Array(types.length).fill('?').join(', ') + ") ";
|
||||||
|
params.push(...types);
|
||||||
|
}
|
||||||
|
if (tagIDs) {
|
||||||
|
sql += "AND tagID IN (" + new Array(tagIDs.length).fill('?').join(', ') + ") ";
|
||||||
|
params.push(...tagIDs);
|
||||||
|
}
|
||||||
|
// Not a perfect locale sort, but speeds up the sort in the tag selector later without any
|
||||||
|
// discernible performance cost
|
||||||
|
sql += "ORDER BY name COLLATE NOCASE";
|
||||||
|
var rows = await Zotero.DB.columnQueryAsync(sql, params);
|
||||||
|
return rows.map((row) => {
|
||||||
|
var [tagID, type] = row.split(':');
|
||||||
|
return this.cleanData({
|
||||||
|
tag: Zotero.Tags.getName(parseInt(tagID)),
|
||||||
|
type: type
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -105,7 +105,7 @@ var ZoteroPane = new function()
|
||||||
this.updateWindow();
|
this.updateWindow();
|
||||||
this.updateToolbarPosition();
|
this.updateToolbarPosition();
|
||||||
});
|
});
|
||||||
window.setTimeout(ZoteroPane_Local.updateToolbarPosition, 0);
|
window.setTimeout(this.updateToolbarPosition.bind(this), 0);
|
||||||
|
|
||||||
Zotero.updateQuickSearchBox(document);
|
Zotero.updateQuickSearchBox(document);
|
||||||
|
|
||||||
|
@ -1103,13 +1103,21 @@ var ZoteroPane = new function()
|
||||||
this.tagSelector = Zotero.TagSelector.init(
|
this.tagSelector = Zotero.TagSelector.init(
|
||||||
document.getElementById('zotero-tag-selector'),
|
document.getElementById('zotero-tag-selector'),
|
||||||
{
|
{
|
||||||
onSelection: this.updateTagFilter.bind(this)
|
container: 'zotero-tag-selector-container',
|
||||||
|
onSelection: this.updateTagFilter.bind(this),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
this.handleTagSelectorResize = Zotero.Utilities.debounce(function() {
|
||||||
|
if (this.tagSelectorShown()) {
|
||||||
|
this.tagSelector.handleResize();
|
||||||
|
}
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Sets the tag filter on the items view
|
* Sets the tag filter on the items view
|
||||||
*/
|
*/
|
||||||
|
@ -1120,7 +1128,7 @@ var ZoteroPane = new function()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
this.toggleTagSelector = Zotero.Promise.coroutine(function* () {
|
this.toggleTagSelector = function () {
|
||||||
var container = document.getElementById('zotero-tag-selector-container');
|
var container = document.getElementById('zotero-tag-selector-container');
|
||||||
var showing = container.getAttribute('collapsed') == 'true';
|
var showing = container.getAttribute('collapsed') == 'true';
|
||||||
container.setAttribute('collapsed', !showing);
|
container.setAttribute('collapsed', !showing);
|
||||||
|
@ -1129,14 +1137,15 @@ var ZoteroPane = new function()
|
||||||
// and focus filter textbox
|
// and focus filter textbox
|
||||||
if (showing) {
|
if (showing) {
|
||||||
this.initTagSelector();
|
this.initTagSelector();
|
||||||
yield this.setTagScope();
|
|
||||||
ZoteroPane.tagSelector.focusTextbox();
|
ZoteroPane.tagSelector.focusTextbox();
|
||||||
|
this.setTagScope();
|
||||||
}
|
}
|
||||||
// If hiding, clear selection
|
// If hiding, clear selection
|
||||||
else {
|
else {
|
||||||
ZoteroPane.tagSelector.uninit();
|
ZoteroPane.tagSelector.uninit();
|
||||||
|
ZoteroPane.tagSelector = null;
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
|
||||||
this.tagSelectorShown = function () {
|
this.tagSelectorShown = function () {
|
||||||
|
@ -1153,7 +1162,7 @@ var ZoteroPane = new function()
|
||||||
*
|
*
|
||||||
* Passed to the items tree to trigger on changes
|
* Passed to the items tree to trigger on changes
|
||||||
*/
|
*/
|
||||||
this.setTagScope = async function () {
|
this.setTagScope = function () {
|
||||||
var collectionTreeRow = self.getCollectionTreeRow();
|
var collectionTreeRow = self.getCollectionTreeRow();
|
||||||
if (self.tagSelectorShown()) {
|
if (self.tagSelectorShown()) {
|
||||||
if (collectionTreeRow.editable) {
|
if (collectionTreeRow.editable) {
|
||||||
|
@ -1163,9 +1172,8 @@ var ZoteroPane = new function()
|
||||||
ZoteroPane_Local.tagSelector.setMode('view');
|
ZoteroPane_Local.tagSelector.setMode('view');
|
||||||
}
|
}
|
||||||
ZoteroPane_Local.tagSelector.onItemViewChanged({
|
ZoteroPane_Local.tagSelector.onItemViewChanged({
|
||||||
collectionTreeRow,
|
|
||||||
libraryID: collectionTreeRow.ref.libraryID,
|
libraryID: collectionTreeRow.ref.libraryID,
|
||||||
tagsInScope: await collectionTreeRow.getChildTags()
|
collectionTreeRow
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
@ -4849,8 +4857,9 @@ var ZoteroPane = new function()
|
||||||
var itemToolbar = document.getElementById("zotero-item-toolbar");
|
var itemToolbar = document.getElementById("zotero-item-toolbar");
|
||||||
var tagSelector = document.getElementById("zotero-tag-selector");
|
var tagSelector = document.getElementById("zotero-tag-selector");
|
||||||
|
|
||||||
collectionsToolbar.style.width = collectionsPane.boxObject.width + 'px';
|
var collectionsPaneWidth = collectionsPane.boxObject.width + 'px';
|
||||||
tagSelector.style.maxWidth = collectionsPane.boxObject.width + 'px';
|
collectionsToolbar.style.width = collectionsPaneWidth;
|
||||||
|
tagSelector.style.maxWidth = collectionsPaneWidth;
|
||||||
|
|
||||||
if (stackedLayout || itemPane.collapsed) {
|
if (stackedLayout || itemPane.collapsed) {
|
||||||
// The itemsToolbar and itemToolbar share the same space, and it seems best to use some flex attribute from right (because there might be other icons appearing or vanishing).
|
// The itemsToolbar and itemToolbar share the same space, and it seems best to use some flex attribute from right (because there might be other icons appearing or vanishing).
|
||||||
|
@ -4876,6 +4885,8 @@ var ZoteroPane = new function()
|
||||||
// Allow item pane to shrink to available height in stacked mode, but don't expand to be too
|
// Allow item pane to shrink to available height in stacked mode, but don't expand to be too
|
||||||
// wide when there's no persisted width in non-stacked mode
|
// wide when there's no persisted width in non-stacked mode
|
||||||
itemPane.setAttribute("flex", stackedLayout ? 1 : 0);
|
itemPane.setAttribute("flex", stackedLayout ? 1 : 0);
|
||||||
|
|
||||||
|
this.handleTagSelectorResize();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -306,8 +306,12 @@
|
||||||
ondragover="return ZoteroPane_Local.collectionsView.onDragOver(event)"
|
ondragover="return ZoteroPane_Local.collectionsView.onDragOver(event)"
|
||||||
ondrop="return ZoteroPane_Local.collectionsView.onDrop(event)"/>
|
ondrop="return ZoteroPane_Local.collectionsView.onDrop(event)"/>
|
||||||
</tree>
|
</tree>
|
||||||
<splitter id="zotero-tags-splitter" collapse="after"
|
<splitter
|
||||||
zotero-persist="state">
|
id="zotero-tags-splitter"
|
||||||
|
collapse="after"
|
||||||
|
zotero-persist="state"
|
||||||
|
onmousemove="if (this.getAttribute('state') == 'dragging') { ZoteroPane.handleTagSelectorResize(); }"
|
||||||
|
>
|
||||||
<grippy oncommand="ZoteroPane_Local.toggleTagSelector()"/>
|
<grippy oncommand="ZoteroPane_Local.toggleTagSelector()"/>
|
||||||
</splitter>
|
</splitter>
|
||||||
<!-- 'collapsed' is no longer necessary here due to the persisted 'state' on
|
<!-- 'collapsed' is no longer necessary here due to the persisted 'state' on
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
textbox {
|
textbox {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
row > label {
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
|
46
package-lock.json
generated
46
package-lock.json
generated
|
@ -647,6 +647,23 @@
|
||||||
"@babel/plugin-transform-react-jsx-source": "^7.0.0"
|
"@babel/plugin-transform-react-jsx-source": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"@babel/runtime": {
|
||||||
|
"version": "7.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.4.2.tgz",
|
||||||
|
"integrity": "sha512-7Bl2rALb7HpvXFL7TETNzKSAeBVCPHELzc0C//9FCxN8nsiueWSJBqaF+2oIJScyILStASR/Cx5WMkXGYTiJFA==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"regenerator-runtime": "^0.13.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"regenerator-runtime": {
|
||||||
|
"version": "0.13.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.2.tgz",
|
||||||
|
"integrity": "sha512-S/TQAZJO+D3m9xeN1WTI8dLKBBiRgXBlTJvbWjCThHWZj9EvHK70Ff50/tYj2J/fvBY6JtFVwRuazHN2E7M9BA==",
|
||||||
|
"dev": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"@babel/template": {
|
"@babel/template": {
|
||||||
"version": "7.2.2",
|
"version": "7.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.2.2.tgz",
|
||||||
|
@ -2139,6 +2156,15 @@
|
||||||
"esutils": "^2.0.2"
|
"esutils": "^2.0.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"dom-helpers": {
|
||||||
|
"version": "3.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-3.4.0.tgz",
|
||||||
|
"integrity": "sha512-LnuPJ+dwqKDIyotW1VzmOZ5TONUN7CwkCR5hrgawTUbkBGYdeoNLZo6nNfGkCrjtE1nXXaj7iMMpDa8/d9WoIA==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"@babel/runtime": "^7.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"domain-browser": {
|
"domain-browser": {
|
||||||
"version": "1.1.7",
|
"version": "1.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.1.7.tgz",
|
||||||
|
@ -5448,6 +5474,26 @@
|
||||||
"invariant": "^2.1.1"
|
"invariant": "^2.1.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"react-lifecycles-compat": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
|
"react-virtualized": {
|
||||||
|
"version": "9.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.21.0.tgz",
|
||||||
|
"integrity": "sha512-duKD2HvO33mqld4EtQKm9H9H0p+xce1c++2D5xn59Ma7P8VT7CprfAe5hwjd1OGkyhqzOZiTMlTal7LxjH5yBQ==",
|
||||||
|
"dev": true,
|
||||||
|
"requires": {
|
||||||
|
"babel-runtime": "^6.26.0",
|
||||||
|
"classnames": "^2.2.3",
|
||||||
|
"dom-helpers": "^2.4.0 || ^3.0.0",
|
||||||
|
"loose-envify": "^1.3.0",
|
||||||
|
"prop-types": "^15.6.0",
|
||||||
|
"react-lifecycles-compat": "^3.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"read-only-stream": {
|
"read-only-stream": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-only-stream/-/read-only-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-only-stream/-/read-only-stream-2.0.0.tgz",
|
||||||
|
|
|
@ -44,6 +44,7 @@
|
||||||
"mocha": "^3.5.3",
|
"mocha": "^3.5.3",
|
||||||
"multimatch": "^2.1.0",
|
"multimatch": "^2.1.0",
|
||||||
"node-sass": "^4.11.0",
|
"node-sass": "^4.11.0",
|
||||||
|
"react-virtualized": "^9.21.0",
|
||||||
"sinon": "^4.5.0",
|
"sinon": "^4.5.0",
|
||||||
"universalify": "^0.1.1"
|
"universalify": "^0.1.1"
|
||||||
}
|
}
|
||||||
|
|
1
resource/react-virtualized.js
vendored
Symbolic link
1
resource/react-virtualized.js
vendored
Symbolic link
|
@ -0,0 +1 @@
|
||||||
|
../node_modules/react-virtualized/dist/umd/react-virtualized.js
|
|
@ -29,16 +29,24 @@ async function babelWorker(ev) {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let contents = await fs.readFile(sourcefile, 'utf8');
|
let contents = await fs.readFile(sourcefile, 'utf8');
|
||||||
|
// Patch react
|
||||||
if (sourcefile === 'resource/react.js') {
|
if (sourcefile === 'resource/react.js') {
|
||||||
// patch react
|
|
||||||
transformed = contents.replace('instanceof Error', '.constructor.name == "Error"')
|
transformed = contents.replace('instanceof Error', '.constructor.name == "Error"')
|
||||||
} else if (sourcefile === 'resource/react-dom.js') {
|
}
|
||||||
// and react-dom
|
// Patch react-dom
|
||||||
|
else if (sourcefile === 'resource/react-dom.js') {
|
||||||
transformed = contents.replace(/ ownerDocument\.createElement\((.*?)\)/gi, 'ownerDocument.createElementNS(HTML_NAMESPACE, $1)')
|
transformed = contents.replace(/ ownerDocument\.createElement\((.*?)\)/gi, 'ownerDocument.createElementNS(HTML_NAMESPACE, $1)')
|
||||||
.replace('element instanceof win.HTMLIFrameElement',
|
.replace('element instanceof win.HTMLIFrameElement',
|
||||||
'typeof element != "undefined" && element.tagName.toLowerCase() == "iframe"')
|
'typeof element != "undefined" && element.tagName.toLowerCase() == "iframe"')
|
||||||
.replace("isInputEventSupported = false", 'isInputEventSupported = true');
|
.replace("isInputEventSupported = false", 'isInputEventSupported = true');
|
||||||
} else if ('ignore' in options && options.ignore.some(ignoreGlob => multimatch(sourcefile, ignoreGlob).length)) {
|
}
|
||||||
|
// Patch react-virtualized
|
||||||
|
else if (sourcefile === 'resource/react-virtualized.js') {
|
||||||
|
transformed = contents.replace('scrollDiv = document.createElement("div")', 'scrollDiv = document.createElementNS("http://www.w3.org/1999/xhtml", "div")')
|
||||||
|
.replace('document.body.appendChild(scrollDiv)', 'document.documentElement.appendChild(scrollDiv)')
|
||||||
|
.replace('document.body.removeChild(scrollDiv)', 'document.documentElement.removeChild(scrollDiv)');
|
||||||
|
}
|
||||||
|
else if ('ignore' in options && options.ignore.some(ignoreGlob => multimatch(sourcefile, ignoreGlob).length)) {
|
||||||
transformed = contents;
|
transformed = contents;
|
||||||
isSkipped = true;
|
isSkipped = true;
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -31,6 +31,7 @@ const symlinkFiles = [
|
||||||
'resource/**/*',
|
'resource/**/*',
|
||||||
'!resource/react.js',
|
'!resource/react.js',
|
||||||
'!resource/react-dom.js',
|
'!resource/react-dom.js',
|
||||||
|
'!resource/react-virtualized.js',
|
||||||
'update.rdf'
|
'update.rdf'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -64,6 +65,7 @@ const jsFiles = [
|
||||||
// Special handling for React -- see note above
|
// Special handling for React -- see note above
|
||||||
'resource/react.js',
|
'resource/react.js',
|
||||||
'resource/react-dom.js',
|
'resource/react-dom.js',
|
||||||
|
'resource/react-virtualized.js',
|
||||||
];
|
];
|
||||||
|
|
||||||
const scssFiles = [
|
const scssFiles = [
|
||||||
|
|
|
@ -1,6 +1,19 @@
|
||||||
//
|
//
|
||||||
// Tag selector
|
// Tag selector
|
||||||
// --------------------------------------------------
|
// --------------------------------------------------
|
||||||
|
#zotero-tag-selector-container {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
#zotero-tag-selector-container[collapsed=true] {
|
||||||
|
visibility: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
#zotero-tag-selector {
|
||||||
|
min-height: 100px;
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
.tag-selector {
|
.tag-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
@ -9,14 +22,19 @@
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-selector-container {
|
.tag-selector-list-container {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
overflow: auto;
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
background-color: $tag-selector-bg;
|
background-color: $tag-selector-bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tag-selector-list-container > div {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.tag-selector-message {
|
.tag-selector-message {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
@ -32,7 +50,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.tag-selector-filter-container {
|
.tag-selector-filter-container {
|
||||||
height: auto;
|
height: 30px;
|
||||||
flex: 0 0 1em;
|
flex: 0 0 1em;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
@ -53,13 +71,10 @@
|
||||||
|
|
||||||
.tag-selector-item {
|
.tag-selector-item {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: inline-block;
|
|
||||||
margin: .15em .05em .15em .3em;
|
|
||||||
padding: 0 .25em 0 .25em;
|
|
||||||
max-width: 250px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: pre;
|
||||||
|
padding: 1px 4px 3px;
|
||||||
|
|
||||||
&.colored {
|
&.colored {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
@ -75,17 +90,3 @@
|
||||||
background: $shade-6;
|
background: $shade-6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#zotero-tag-selector-container {
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
#zotero-tag-selector-container[collapsed=true] {
|
|
||||||
visibility: collapse;
|
|
||||||
}
|
|
||||||
|
|
||||||
#zotero-tag-selector {
|
|
||||||
min-height: 100px;
|
|
||||||
width: 100%;
|
|
||||||
display: flex;
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
describe("Tag Selector", function () {
|
describe("Tag Selector", function () {
|
||||||
var win, doc, collectionsView, tagSelectorElem, tagSelector;
|
var libraryID, win, doc, collectionsView, tagSelectorElem, tagSelector;
|
||||||
|
|
||||||
var clearTagColors = Zotero.Promise.coroutine(function* (libraryID) {
|
var clearTagColors = Zotero.Promise.coroutine(function* (libraryID) {
|
||||||
var tagColors = Zotero.Tags.getColors(libraryID);
|
var tagColors = Zotero.Tags.getColors(libraryID);
|
||||||
|
@ -11,17 +11,25 @@ describe("Tag Selector", function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
function getColoredTags() {
|
function getColoredTags() {
|
||||||
var elems = Array.from(tagSelectorElem.querySelectorAll('.tag-selector-item.colored'));
|
return [...getColoredTagElements()].map(elem => elem.textContent);
|
||||||
return elems.map(elem => elem.textContent);
|
}
|
||||||
|
|
||||||
|
function getColoredTagElements() {
|
||||||
|
return tagSelectorElem.querySelectorAll('.tag-selector-item.colored');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRegularTags() {
|
function getRegularTags() {
|
||||||
var elems = Array.from(tagSelectorElem.querySelectorAll('.tag-selector-item:not(.colored)'));
|
return [...getRegularTagElements()].map(elem => elem.textContent);
|
||||||
return elems.map(elem => elem.textContent);
|
}
|
||||||
|
|
||||||
|
function getRegularTagElements() {
|
||||||
|
return tagSelectorElem.querySelectorAll('.tag-selector-item:not(.colored)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
before(function* () {
|
before(function* () {
|
||||||
|
libraryID = Zotero.Libraries.userLibraryID;
|
||||||
|
|
||||||
win = yield loadZoteroPane();
|
win = yield loadZoteroPane();
|
||||||
doc = win.document;
|
doc = win.document;
|
||||||
collectionsView = win.ZoteroPane.collectionsView;
|
collectionsView = win.ZoteroPane.collectionsView;
|
||||||
|
@ -32,15 +40,18 @@ describe("Tag Selector", function () {
|
||||||
yield Zotero.Promise.delay(100);
|
yield Zotero.Promise.delay(100);
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(function* () {
|
beforeEach(async function () {
|
||||||
var libraryID = Zotero.Libraries.userLibraryID;
|
await selectLibrary(win);
|
||||||
yield clearTagColors(libraryID);
|
await clearTagColors(libraryID);
|
||||||
// Default "Display All Tags in This Library" off
|
// Default "Display All Tags in This Library" off
|
||||||
tagSelector.displayAllTags = false;
|
tagSelector.displayAllTags = false;
|
||||||
tagSelector.selectedTags = new Set();
|
tagSelector.selectedTags = new Set();
|
||||||
tagSelector.handleSearch('');
|
tagSelector.handleSearch('');
|
||||||
tagSelector.onItemViewChanged({libraryID});
|
tagSelector.onItemViewChanged({
|
||||||
yield waitForTagSelector(win);
|
collectionTreeRow: win.ZoteroPane.getCollectionTreeRow(),
|
||||||
|
libraryID
|
||||||
|
});
|
||||||
|
await waitForTagSelector(win);
|
||||||
});
|
});
|
||||||
|
|
||||||
after(function () {
|
after(function () {
|
||||||
|
@ -48,7 +59,6 @@ describe("Tag Selector", function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should sort colored tags by assigned number key", async function () {
|
it("should sort colored tags by assigned number key", async function () {
|
||||||
var libraryID = Zotero.Libraries.userLibraryID;
|
|
||||||
var collection = await createDataObject('collection');
|
var collection = await createDataObject('collection');
|
||||||
|
|
||||||
await Zotero.Tags.setColor(libraryID, "B", '#AAAAAA', 1);
|
await Zotero.Tags.setColor(libraryID, "B", '#AAAAAA', 1);
|
||||||
|
@ -156,25 +166,17 @@ describe("Tag Selector", function () {
|
||||||
it("should show all tags in library when true", function* () {
|
it("should show all tags in library when true", function* () {
|
||||||
tagSelector.displayAllTags = true;
|
tagSelector.displayAllTags = true;
|
||||||
|
|
||||||
|
var tag1 = 'A ' + Zotero.Utilities.randomString();
|
||||||
|
var tag2 = 'B ' + Zotero.Utilities.randomString();
|
||||||
|
var tag3 = 'C ' + Zotero.Utilities.randomString();
|
||||||
|
|
||||||
var collection = yield createDataObject('collection');
|
var collection = yield createDataObject('collection');
|
||||||
var item1 = createUnsavedDataObject('item');
|
var item1 = createUnsavedDataObject('item');
|
||||||
item1.setTags([
|
item1.setTags([tag1]);
|
||||||
{
|
|
||||||
tag: "A"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
var item2 = createUnsavedDataObject('item', { collections: [collection.id] });
|
var item2 = createUnsavedDataObject('item', { collections: [collection.id] });
|
||||||
item2.setTags([
|
item2.setTags([tag2]);
|
||||||
{
|
|
||||||
tag: "B"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
var item3 = createUnsavedDataObject('item', { collections: [collection.id] });
|
var item3 = createUnsavedDataObject('item', { collections: [collection.id] });
|
||||||
item3.setTags([
|
item3.setTags([tag3]);
|
||||||
{
|
|
||||||
tag: "C"
|
|
||||||
}
|
|
||||||
]);
|
|
||||||
var promise = waitForTagSelector(win);
|
var promise = waitForTagSelector(win);
|
||||||
yield Zotero.DB.executeTransaction(function* () {
|
yield Zotero.DB.executeTransaction(function* () {
|
||||||
yield item1.save();
|
yield item1.save();
|
||||||
|
@ -184,7 +186,15 @@ describe("Tag Selector", function () {
|
||||||
yield promise;
|
yield promise;
|
||||||
|
|
||||||
var tags = getRegularTags();
|
var tags = getRegularTags();
|
||||||
assert.sameMembers(tags, ['A', 'B', 'C']);
|
assert.includeMembers(tags, [tag1, tag2, tag3]);
|
||||||
|
assert.isBelow(tags.indexOf(tag1), tags.indexOf(tag2));
|
||||||
|
assert.isBelow(tags.indexOf(tag2), tags.indexOf(tag3));
|
||||||
|
|
||||||
|
var elems = getRegularTagElements();
|
||||||
|
// Tag not associated with any items in this collection should be disabled
|
||||||
|
assert.isTrue(elems[tags.indexOf(tag1)].classList.contains('disabled'));
|
||||||
|
assert.isFalse(elems[tags.indexOf(tag2)].classList.contains('disabled'));
|
||||||
|
assert.isFalse(elems[tags.indexOf(tag3)].classList.contains('disabled'));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -199,16 +209,18 @@ describe("Tag Selector", function () {
|
||||||
await promise;
|
await promise;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add item with tag to library root
|
// Add item with tags to library root
|
||||||
var tagA = Zotero.Utilities.randomString();
|
var tag1 = 'A ' + Zotero.Utilities.randomString();
|
||||||
var tagB = Zotero.Utilities.randomString();
|
var tag2 = 'M ' + Zotero.Utilities.randomString();
|
||||||
|
var tag3 = 'Z ' + Zotero.Utilities.randomString();
|
||||||
|
|
||||||
var item = createUnsavedDataObject('item');
|
var item = createUnsavedDataObject('item');
|
||||||
item.setTags([
|
item.setTags([
|
||||||
{
|
{
|
||||||
tag: tagA
|
tag: tag3
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
tag: tagB,
|
tag: tag1,
|
||||||
type: 1
|
type: 1
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
|
@ -216,7 +228,20 @@ describe("Tag Selector", function () {
|
||||||
await item.saveTx();
|
await item.saveTx();
|
||||||
await promise;
|
await promise;
|
||||||
|
|
||||||
assert.includeMembers(getRegularTags(), [tagA, tagB]);
|
var tags = getRegularTags();
|
||||||
|
assert.includeMembers(tags, [tag1, tag3]);
|
||||||
|
assert.isBelow(tags.indexOf(tag1), tags.indexOf(tag3));
|
||||||
|
|
||||||
|
// Add another tag to the item, sorted between the two other tags
|
||||||
|
promise = waitForTagSelector(win);
|
||||||
|
item.addTag(tag2);
|
||||||
|
await item.saveTx();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
var tags = getRegularTags();
|
||||||
|
assert.includeMembers(tags, [tag1, tag2, tag3]);
|
||||||
|
assert.isBelow(tags.indexOf(tag1), tags.indexOf(tag2));
|
||||||
|
assert.isBelow(tags.indexOf(tag2), tags.indexOf(tag3));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should add a tag when an item is added in a collection", function* () {
|
it("should add a tag when an item is added in a collection", function* () {
|
||||||
|
@ -244,7 +269,112 @@ describe("Tag Selector", function () {
|
||||||
|
|
||||||
// Tag selector should show the new item's tag
|
// Tag selector should show the new item's tag
|
||||||
assert.equal(getRegularTags().length, 1);
|
assert.equal(getRegularTags().length, 1);
|
||||||
})
|
});
|
||||||
|
|
||||||
|
it("should update colored tag disabled state when items are added to and removed from collection", async function () {
|
||||||
|
var tag1 = 'A ' + Zotero.Utilities.randomString();
|
||||||
|
var tag2 = 'B ' + Zotero.Utilities.randomString();
|
||||||
|
var tag3 = 'C ' + Zotero.Utilities.randomString();
|
||||||
|
|
||||||
|
// Add collection
|
||||||
|
var promise = waitForTagSelector(win);
|
||||||
|
var collection = await createDataObject('collection');
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
var elems = getColoredTagElements();
|
||||||
|
assert.lengthOf(elems, 0);
|
||||||
|
|
||||||
|
await Zotero.Tags.setColor(libraryID, tag1, '#AAAAAA', 1);
|
||||||
|
await Zotero.Tags.setColor(libraryID, tag2, '#BBBBBB', 2);
|
||||||
|
await Zotero.Tags.setColor(libraryID, tag3, '#CCCCCC', 3);
|
||||||
|
|
||||||
|
// Colored tags should appear initially as disabled
|
||||||
|
elems = getColoredTagElements();
|
||||||
|
assert.lengthOf(elems, 3);
|
||||||
|
assert.isTrue(elems[0].classList.contains('disabled'));
|
||||||
|
assert.isTrue(elems[1].classList.contains('disabled'));
|
||||||
|
assert.isTrue(elems[2].classList.contains('disabled'));
|
||||||
|
|
||||||
|
// Add items with tags to collection
|
||||||
|
promise = waitForTagSelector(win)
|
||||||
|
var item1;
|
||||||
|
var item2;
|
||||||
|
await Zotero.DB.executeTransaction(async function () {
|
||||||
|
item1 = createUnsavedDataObject('item', { collections: [collection.id], tags: [tag1, tag2] });
|
||||||
|
item2 = createUnsavedDataObject('item', { collections: [collection.id], tags: [tag2] });
|
||||||
|
await item1.save();
|
||||||
|
await item2.save();
|
||||||
|
});
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
elems = getColoredTagElements();
|
||||||
|
assert.lengthOf(elems, 3);
|
||||||
|
// Assigned tags should be enabled
|
||||||
|
assert.isFalse(elems[0].classList.contains('disabled'));
|
||||||
|
assert.isFalse(elems[1].classList.contains('disabled'));
|
||||||
|
// Unassigned tag should still be disabled
|
||||||
|
assert.isTrue(elems[2].classList.contains('disabled'));
|
||||||
|
|
||||||
|
// Remove item from collection
|
||||||
|
promise = waitForTagSelector(win)
|
||||||
|
item1.removeFromCollection(collection.id);
|
||||||
|
await item1.saveTx();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
// A and C should be disabled
|
||||||
|
elems = getColoredTagElements();
|
||||||
|
assert.lengthOf(elems, 3);
|
||||||
|
assert.isTrue(elems[0].classList.contains('disabled'));
|
||||||
|
assert.isFalse(elems[1].classList.contains('disabled'));
|
||||||
|
assert.isTrue(elems[2].classList.contains('disabled'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should update colored tag disabled state when tags are added to and removed from items", async function () {
|
||||||
|
var tag1 = 'A ' + Zotero.Utilities.randomString();
|
||||||
|
var tag2 = 'B ' + Zotero.Utilities.randomString();
|
||||||
|
var tag3 = 'C ' + Zotero.Utilities.randomString();
|
||||||
|
|
||||||
|
var elems = getColoredTagElements();
|
||||||
|
assert.lengthOf(elems, 0);
|
||||||
|
|
||||||
|
await Zotero.Tags.setColor(libraryID, tag1, '#AAAAAA', 1);
|
||||||
|
await Zotero.Tags.setColor(libraryID, tag2, '#BBBBBB', 2);
|
||||||
|
await Zotero.Tags.setColor(libraryID, tag3, '#CCCCCC', 3);
|
||||||
|
|
||||||
|
// Add items to collection
|
||||||
|
var item1 = await createDataObject('item');
|
||||||
|
var item2 = await createDataObject('item');
|
||||||
|
|
||||||
|
var promise = waitForTagSelector(win)
|
||||||
|
await Zotero.DB.executeTransaction(async function () {
|
||||||
|
item1.setTags([tag1, tag2]);
|
||||||
|
item2.setTags([tag1]);
|
||||||
|
await item1.save();
|
||||||
|
await item2.save();
|
||||||
|
});
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
elems = getColoredTagElements();
|
||||||
|
assert.lengthOf(elems, 3);
|
||||||
|
// Assigned tags should be enabled
|
||||||
|
assert.isFalse(elems[0].classList.contains('disabled'));
|
||||||
|
assert.isFalse(elems[1].classList.contains('disabled'));
|
||||||
|
// Unassigned tag should still be disabled
|
||||||
|
assert.isTrue(elems[2].classList.contains('disabled'));
|
||||||
|
|
||||||
|
// Remove tags from one item
|
||||||
|
promise = waitForTagSelector(win)
|
||||||
|
item1.setTags([]);
|
||||||
|
await item1.saveTx();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
// B and C should be disabled
|
||||||
|
elems = getColoredTagElements();
|
||||||
|
assert.lengthOf(elems, 3);
|
||||||
|
assert.isFalse(elems[0].classList.contains('disabled'));
|
||||||
|
assert.isTrue(elems[1].classList.contains('disabled'));
|
||||||
|
assert.isTrue(elems[2].classList.contains('disabled'));
|
||||||
|
});
|
||||||
|
|
||||||
it("should add a tag when an item is added to a collection", function* () {
|
it("should add a tag when an item is added to a collection", function* () {
|
||||||
var promise, tagSelector;
|
var promise, tagSelector;
|
||||||
|
@ -264,9 +394,7 @@ describe("Tag Selector", function () {
|
||||||
tag: 'C'
|
tag: 'C'
|
||||||
}
|
}
|
||||||
]);
|
]);
|
||||||
promise = waitForTagSelector(win)
|
|
||||||
yield item.saveTx();
|
yield item.saveTx();
|
||||||
yield promise;
|
|
||||||
|
|
||||||
// Tag selector should still be empty in collection
|
// Tag selector should still be empty in collection
|
||||||
assert.equal(getRegularTags().length, 0);
|
assert.equal(getRegularTags().length, 0);
|
||||||
|
@ -281,8 +409,6 @@ describe("Tag Selector", function () {
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should show a colored tag at the top of the list even when linked to no items", function* () {
|
it("should show a colored tag at the top of the list even when linked to no items", function* () {
|
||||||
var libraryID = Zotero.Libraries.userLibraryID;
|
|
||||||
|
|
||||||
var tagElems = tagSelectorElem.querySelectorAll('.tag-selector-item');
|
var tagElems = tagSelectorElem.querySelectorAll('.tag-selector-item');
|
||||||
var count = tagElems.length;
|
var count = tagElems.length;
|
||||||
|
|
||||||
|
@ -295,8 +421,6 @@ describe("Tag Selector", function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shouldn't re-insert a new tag that matches an existing color", function* () {
|
it("shouldn't re-insert a new tag that matches an existing color", function* () {
|
||||||
var libraryID = Zotero.Libraries.userLibraryID;
|
|
||||||
|
|
||||||
// Add A and B as colored tags without any items
|
// Add A and B as colored tags without any items
|
||||||
yield Zotero.Tags.setColor(libraryID, "A", '#CC9933', 1);
|
yield Zotero.Tags.setColor(libraryID, "A", '#CC9933', 1);
|
||||||
yield Zotero.Tags.setColor(libraryID, "B", '#990000', 2);
|
yield Zotero.Tags.setColor(libraryID, "B", '#990000', 2);
|
||||||
|
@ -383,6 +507,43 @@ describe("Tag Selector", function () {
|
||||||
assert.equal(getRegularTags().length, 0);
|
assert.equal(getRegularTags().length, 0);
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it("shouldn't remove a tag when a tag is removed from an item in a collection in displayAllTags mode", async function () {
|
||||||
|
tagSelector.displayAllTags = true;
|
||||||
|
|
||||||
|
var tag = Zotero.Utilities.randomString();
|
||||||
|
|
||||||
|
// Add item with tag not in collection
|
||||||
|
var promise = waitForTagSelector(win);
|
||||||
|
var item1 = await createDataObject('item', { tags: [tag] });
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
promise = waitForTagSelector(win);
|
||||||
|
var collection = await createDataObject('collection');
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
// Add item with tag to collection
|
||||||
|
promise = waitForTagSelector(win);
|
||||||
|
var item2 = await createDataObject('item', { collections: [collection.id], tags: [tag] });
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
// Tag selector should show the new item's tag
|
||||||
|
var tags = getRegularTags();
|
||||||
|
assert.include(tags, tag);
|
||||||
|
var elems = getRegularTagElements();
|
||||||
|
assert.isFalse(elems[tags.indexOf(tag)].classList.contains('disabled'));
|
||||||
|
|
||||||
|
item2.removeTag(tag);
|
||||||
|
promise = waitForTagSelector(win);
|
||||||
|
await item2.saveTx();
|
||||||
|
await promise;
|
||||||
|
|
||||||
|
// Tag selector should still show the removed item's tag
|
||||||
|
tags = getRegularTags();
|
||||||
|
assert.include(tags, tag);
|
||||||
|
elems = getRegularTagElements();
|
||||||
|
assert.isTrue(elems[tags.indexOf(tag)].classList.contains('disabled'));
|
||||||
|
});
|
||||||
|
|
||||||
it("should remove a tag when a tag is deleted for a library", function* () {
|
it("should remove a tag when a tag is deleted for a library", function* () {
|
||||||
yield selectLibrary(win);
|
yield selectLibrary(win);
|
||||||
|
|
||||||
|
@ -411,7 +572,6 @@ describe("Tag Selector", function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should deselect a tag when removed from the last item in this view", async function () {
|
it("should deselect a tag when removed from the last item in this view", async function () {
|
||||||
var libraryID = Zotero.Libraries.userLibraryID;
|
|
||||||
await selectLibrary(win);
|
await selectLibrary(win);
|
||||||
|
|
||||||
var tag1 = Zotero.Utilities.randomString();
|
var tag1 = Zotero.Utilities.randomString();
|
||||||
|
@ -447,7 +607,6 @@ describe("Tag Selector", function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should deselect a tag when deleted from a library", async function () {
|
it("should deselect a tag when deleted from a library", async function () {
|
||||||
var libraryID = Zotero.Libraries.userLibraryID;
|
|
||||||
await selectLibrary(win);
|
await selectLibrary(win);
|
||||||
|
|
||||||
var promise = waitForTagSelector(win, 2);
|
var promise = waitForTagSelector(win, 2);
|
||||||
|
@ -517,7 +676,6 @@ describe("Tag Selector", function () {
|
||||||
var oldTag = Zotero.Utilities.randomString();
|
var oldTag = Zotero.Utilities.randomString();
|
||||||
var newTag = Zotero.Utilities.randomString();
|
var newTag = Zotero.Utilities.randomString();
|
||||||
|
|
||||||
var libraryID = Zotero.Libraries.userLibraryID;
|
|
||||||
var promise = waitForTagSelector(win);
|
var promise = waitForTagSelector(win);
|
||||||
yield Zotero.Tags.setColor(libraryID, oldTag, "#F3F3F3");
|
yield Zotero.Tags.setColor(libraryID, oldTag, "#F3F3F3");
|
||||||
yield promise;
|
yield promise;
|
||||||
|
|
Loading…
Reference in a new issue