From 5791ffeb16f5b15748ba908422b6cf4bd6c0e7e6 Mon Sep 17 00:00:00 2001 From: Dan Stillman Date: Fri, 8 Nov 2019 03:40:20 -0500 Subject: [PATCH] Reactify item tags box Improvements: - Fixes autocomplete text remaining in field after selection in Fx60 - No more text or icon shifting on select (tested on macOS) Changes: - Tags are now selected on mousedown with no active state, as in web library Regressions: - Tooltip with tag type doesn't appear when hovering over icon - Pressing Tab after modifying a tag loses focus - Right-click in textbox shows custom menu instead of default text editing context menu (Cut/Copy/Paste) To-do: - Switch to this version for note tags box - Style colored tags in autocomplete drop-down? Sort to top? - Only show delete button on row hover, as in web library? --- .../content/zotero-platform/mac/overlay.css | 5 + chrome/content/zotero/components/editable.jsx | 117 +++++ .../zotero/components/editable/content.jsx | 91 ++++ .../content/zotero/components/form/input.jsx | 232 +++++++--- .../content/zotero/components/form/select.jsx | 221 +++++++++ .../zotero/components/form/textArea.jsx | 208 +++++++++ .../zotero/components/itemPane/tagsBox.jsx | 433 ++++++++++++++++++ chrome/content/zotero/components/utils.js | 5 + .../content/zotero/containers/containers.xul | 1 + chrome/content/zotero/containers/tagsBox.xul | 38 ++ .../zotero/containers/tagsBoxContainer.jsx | 123 +++++ chrome/content/zotero/itemPane.js | 70 ++- chrome/content/zotero/itemPane.xul | 10 +- chrome/content/zotero/modules/immutable.js | 57 +++ package-lock.json | 421 +++++++++++++++-- package.json | 8 +- resource/loader.jsm | 3 + resource/react-autosuggest.js | 1 + resource/require.js | 4 +- scripts/config.js | 7 + scss/_zotero-react-client.scss | 3 + scss/components/_autosuggest.scss | 59 +++ scss/components/_editable.scss | 11 + scss/components/_tag-manager.scss | 83 ++++ scss/components/_tagsBox.scss | 91 ++++ test/tests/tagsboxTest.js | 22 +- 26 files changed, 2179 insertions(+), 145 deletions(-) create mode 100644 chrome/content/zotero/components/editable.jsx create mode 100644 chrome/content/zotero/components/editable/content.jsx create mode 100644 chrome/content/zotero/components/form/select.jsx create mode 100644 chrome/content/zotero/components/form/textArea.jsx create mode 100644 chrome/content/zotero/components/itemPane/tagsBox.jsx create mode 100644 chrome/content/zotero/components/utils.js create mode 100644 chrome/content/zotero/containers/tagsBox.xul create mode 100644 chrome/content/zotero/containers/tagsBoxContainer.jsx create mode 100644 chrome/content/zotero/modules/immutable.js create mode 120000 resource/react-autosuggest.js create mode 100644 scss/components/_autosuggest.scss create mode 100644 scss/components/_editable.scss create mode 100644 scss/components/_tag-manager.scss create mode 100644 scss/components/_tagsBox.scss diff --git a/chrome/content/zotero-platform/mac/overlay.css b/chrome/content/zotero-platform/mac/overlay.css index d457d308ff..acb230d456 100644 --- a/chrome/content/zotero-platform/mac/overlay.css +++ b/chrome/content/zotero-platform/mac/overlay.css @@ -1,3 +1,8 @@ +/* Force use of Lucida for HTML input elements (e.g., Editable) */ +input { + font-family: Lucida Grande, Lucida Sans Unicode, Lucida Sans, Geneva, -apple-system, sans-serif !important; +} + #zotero-items-toolbar[state=collapsed] { margin-left: -8px !important; diff --git a/chrome/content/zotero/components/editable.jsx b/chrome/content/zotero/components/editable.jsx new file mode 100644 index 0000000000..eb170f9110 --- /dev/null +++ b/chrome/content/zotero/components/editable.jsx @@ -0,0 +1,117 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2019 Corporation for Digital Scholarship + Vienna, Virginia, USA + https://digitalscholar.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import EditableContent from './editable/content'; +import Input from './form/input'; +import TextAreaInput from './form/textArea'; +import SelectInput from './form/select'; +import { noop } from './utils'; + +class Editable extends React.PureComponent { + get isActive() { + return (this.props.isActive || this.props.isBusy) && !this.props.isDisabled; + } + + get isReadOnly() { + return this.props.isReadOnly || this.props.isBusy; + } + + get className() { + const { input, inputComponent } = this.props; + return { + 'editable': true, + 'editing': this.isActive, + 'textarea': inputComponent === TextAreaInput || input && input.type === TextAreaInput, + 'select': inputComponent === SelectInput || input && input.type === SelectInput, + }; + } + + renderContent() { + const hasChildren = typeof this.props.children !== 'undefined'; + return ( + + { + hasChildren ? + this.props.children : + + } + + ); + } + + renderControls() { + const { input: InputElement, inputComponent: InputComponent } = this.props; + if(InputElement) { + return InputElement; + } else { + const { className, innerRef, ...props } = this.props; + props.ref = innerRef; + + return + } + } + + render() { + const { isDisabled } = this.props; + return ( +
this.props.onClick(event) } + onFocus={ event => this.props.onFocus(event) } + onMouseDown={ event => this.props.onMouseDown(event) } + className={ cx(this.className) } + > + { this.isActive ? this.renderControls() : this.renderContent() } +
+ ); + } + static defaultProps = { + inputComponent: Input, + onClick: noop, + onFocus: noop, + onMouseDown: noop, + }; + + static propTypes = { + children: PropTypes.oneOfType([PropTypes.element, PropTypes.array]), + input: PropTypes.element, + inputComponent: PropTypes.elementType, + isActive: PropTypes.bool, + isBusy: PropTypes.bool, + isDisabled: PropTypes.bool, + isReadOnly: PropTypes.bool, + }; +} + + +export default React.forwardRef((props, ref) => ); \ No newline at end of file diff --git a/chrome/content/zotero/components/editable/content.jsx b/chrome/content/zotero/components/editable/content.jsx new file mode 100644 index 0000000000..d10dcab285 --- /dev/null +++ b/chrome/content/zotero/components/editable/content.jsx @@ -0,0 +1,91 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2019 Corporation for Digital Scholarship + Vienna, Virginia, USA + https://digitalscholar.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import TextAreaInput from '../form/textArea'; +import Select from '../form/select'; + +class EditableContent extends React.PureComponent { + get hasValue() { + const { input, value } = this.props; + return !!(value || input && input.props.value); + } + + get isSelect() { + const { input, inputComponent } = this.props; + return inputComponent === Select || input && input.type == Select; + } + + get isTextarea() { + const { input, inputComponent } = this.props; + return inputComponent === TextAreaInput || input && input.type === TextAreaInput; + } + + get displayValue() { + const { options, display, input } = this.props; + const value = this.props.value || input && input.props.value; + const placeholder = this.props.placeholder || input && input.props.placeholder; + + if(!this.hasValue) { return placeholder; } + if(display) { return display; } + + if(this.isSelect && options) { + const displayValue = options.find(e => e.value == value); + return displayValue ? displayValue.label : value; + } + + return value; + } + + render() { + const className = { + 'editable-content': true, + 'placeholder': !this.hasValue + }; + + return
{ this.displayValue }
; + } + + static defaultProps = { + value: '', + placeholder: '' + }; + + static propTypes = { + display: PropTypes.string, + input: PropTypes.element, + inputComponent: PropTypes.elementType, + options: PropTypes.array, + placeholder: PropTypes.string, + value: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.number + ]) + }; +} + +export default EditableContent; diff --git a/chrome/content/zotero/components/form/input.jsx b/chrome/content/zotero/components/form/input.jsx index d70b131b56..12dfc56d2f 100644 --- a/chrome/content/zotero/components/form/input.jsx +++ b/chrome/content/zotero/components/form/input.jsx @@ -1,116 +1,209 @@ -/* eslint-disable react/no-deprecated */ -'use strict'; - -const React = require('react'); -const PropTypes = require('prop-types'); -const cx = require('classnames'); -const { noop } = () => {}; +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import { noop } from '../utils'; +import { pickKeys } from '@zotero/immutable'; +//import AutoResizer from './auto-resizer'; +import Autosuggest from 'react-autosuggest'; class Input extends React.PureComponent { constructor(props) { super(props); this.state = { + suggestions: [], value: props.value }; + this.suggestions = React.createRef(); + this.showSuggestions = React.createRef(false); + this.preSuggestionValue = React.createRef(); + this.selectedSuggestion = React.createRef(); } cancel(event = null) { - this.props.onCancel && this.props.onCancel(this.hasChanged, event); + this.props.onCancel(this.hasChanged, event); this.hasBeenCancelled = true; - this.input.blur(); + this.props.innerRef.current && this.props.innerRef.current.blur(); } commit(event = null) { - this.props.onCommit && this.props.onCommit(this.state.value, this.hasChanged, event); + this.props.onCommit(this.state.value, this.hasChanged, event); this.hasBeenCommitted = true; } focus() { - if(this.input != null) { - this.input.focus(); - this.props.selectOnFocus && this.input.select(); + if (this.props.innerRef.current != null) { + this.props.innerRef.current.focus(); + this.props.selectOnFocus && this.props.innerRef.current.select(); } } - componentWillReceiveProps({ value }) { + UNSAFE_componentWillReceiveProps({ value }) { if (value !== this.props.value) { this.setState({ value }); } } - handleChange({ target }) { - this.setState({ value: target.value }); - this.props.onChange && this.props.onChange(target.value); + handleChange({ target }, options) { + var newValue = options.newValue || target.value; + this.setState({ + value: newValue, + }); + this.props.onChange(newValue); } handleBlur(event) { - const shouldCancel = this.props.onBlur && this.props.onBlur(event); + if (this.selectedSuggestion.current) { + this.selectedSuggestion.current = null; + return; + } if (this.hasBeenCancelled || this.hasBeenCommitted) { return; } + const shouldCancel = this.props.onBlur(event); shouldCancel ? this.cancel(event) : this.commit(event); } handleFocus(event) { - this.props.selectOnFocus && event.target.select(); - this.props.onFocus && this.props.onFocus(event); + !this.focused && this.props.selectOnFocus && event.target.select(); + // Only focus the input once so that the entered text doesn't get selected when it matches + // a suggestion and the input gets rerendered with the suggestions drop-down + this.focused = true; + this.showSuggestions.current = false; + this.props.onFocus(event); } handleKeyDown(event) { + this.showSuggestions.current = true; switch (event.key) { case 'Escape': this.cancel(event); break; + case 'Enter': - this.commit(event); + if (this.selectedSuggestion.current) { + let value = this.selectedSuggestion.current; + this.selectedSuggestion.current = null; + this.setState({ value }); + } + else { + this.commit(event); + } break; - default: - return; } + this.props.onKeyDown(event); + } + + handlePaste(event) { + this.props.onPaste && this.props.onPaste(event); + } + + // Autosuggest will call this function every time you need to update suggestions. + // You already implemented this logic above, so just use it. + async handleSuggestionsFetchRequested({ value }) { + this.setState({ + suggestions: await this.props.getSuggestions(value) + }); + } + + // Autosuggest will call this function every time you need to clear suggestions. + handleSuggestionsClearRequested() { + this.setState({ + suggestions: [] + }); + } + + getSuggestionValue(suggestion) { + return suggestion; + } + + shouldRenderSuggestions(value) { + return value.length && this.showSuggestions.current; + } + + renderSuggestion(suggestion) { + return + {suggestion} + ; + } + + handleSuggestionSelected = (event, { suggestion, suggestionValue, suggestionIndex, sectionIndex, method }) => { + this.selectedSuggestion.current = suggestionValue; + // focusInputOnSuggestionClick in Autosuggest doesn't work with a custom renderInputComponent, + // so refocus the textbox manually + setTimeout(() => this.props.innerRef.current.focus()); + } + + get value() { + return this.state.value; } get hasChanged() { return this.state.value !== this.props.value; } - render() { + renderInput() { this.hasBeenCancelled = false; this.hasBeenCommitted = false; - const extraProps = Object.keys(this.props).reduce((aggr, key) => { - if(key.match(/^(aria-|data-).*/)) { - aggr[key] = this.props[key]; - } - return aggr; - }, {}); - const input = this.input = input } - required={ this.props.isRequired } - size={ this.props.size } - spellCheck={ this.props.spellCheck } - step={ this.props.step } - tabIndex={ this.props.tabIndex } - type={ this.props.type } - value={ this.state.value } - { ...extraProps } - />; + + const inputProps = { + disabled: this.props.isDisabled, + onBlur: this.handleBlur.bind(this), + onChange: this.handleChange.bind(this), + onFocus: this.handleFocus.bind(this), + onKeyDown: this.handleKeyDown.bind(this), + onPaste: this.handlePaste.bind(this), + readOnly: this.props.isReadOnly, + required: this.props.isRequired, + value: this.state.value, + ...pickKeys(this.props, ['autoFocus', 'className', 'form', 'id', 'inputMode', 'max', + 'maxLength', 'min', 'minLength', 'name', 'placeholder', 'type', 'spellCheck', + 'step', 'tabIndex']), + ...pickKeys(this.props, key => key.match(/^(aria-|data-).*/)) + }; + + var input = this.props.autoComplete ? ( + } + focusInputOnSuggestionClick={false} + shouldRenderSuggestions={this.shouldRenderSuggestions.bind(this)} + inputProps={inputProps} + /> + ) : ( + + ); + + if(this.props.resize) { + /*input = ( + + { input } + + );*/ + } + return input; } + render() { + const className = cx({ + 'input-group': true, + 'input': true, + 'busy': this.props.isBusy + }, this.props.inputGroupClassName); + return ( +
+ { this.renderInput() } +
+ ); + } + static defaultProps = { className: 'form-control', onBlur: noop, @@ -118,17 +211,23 @@ class Input extends React.PureComponent { onChange: noop, onCommit: noop, onFocus: noop, + onKeyDown: noop, + onPaste: noop, tabIndex: -1, type: 'text', value: '', }; static propTypes = { + autoComplete: PropTypes.bool, autoFocus: PropTypes.bool, className: PropTypes.string, form: PropTypes.string, + getSuggestions: PropTypes.func, id: PropTypes.string, + inputGroupClassName: PropTypes.string, inputMode: PropTypes.string, + isBusy: PropTypes.bool, isDisabled: PropTypes.bool, isReadOnly: PropTypes.bool, isRequired: PropTypes.bool, @@ -137,12 +236,15 @@ class Input extends React.PureComponent { min: PropTypes.number, minLength: PropTypes.number, name: PropTypes.string, - onBlur: PropTypes.func, - onCancel: PropTypes.func, - onChange: PropTypes.func, - onCommit: PropTypes.func, - onFocus: PropTypes.func, + onBlur: PropTypes.func.isRequired, + onCancel: PropTypes.func.isRequired, + onChange: PropTypes.func.isRequired, + onCommit: PropTypes.func.isRequired, + onFocus: PropTypes.func.isRequired, + onKeyDown: PropTypes.func, + onPaste: PropTypes.func, placeholder: PropTypes.string, + resize: PropTypes.oneOfType([PropTypes.bool, PropTypes.string]), selectOnFocus: PropTypes.bool, spellCheck: PropTypes.bool, step: PropTypes.number, @@ -152,4 +254,6 @@ class Input extends React.PureComponent { }; } -module.exports = Input; +export default React.forwardRef((props, ref) => ); \ No newline at end of file diff --git a/chrome/content/zotero/components/form/select.jsx b/chrome/content/zotero/components/form/select.jsx new file mode 100644 index 0000000000..d6144b94a6 --- /dev/null +++ b/chrome/content/zotero/components/form/select.jsx @@ -0,0 +1,221 @@ +/* + ***** BEGIN LICENSE BLOCK ***** + + Copyright © 2019 Corporation for Digital Scholarship + Vienna, Virginia, USA + https://digitalscholar.org + + This file is part of Zotero. + + Zotero is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Zotero is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with Zotero. If not, see . + + ***** END LICENSE BLOCK ***** +*/ + +import React from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import { noop } from '../utils'; +//import Spinner from '../ui/spinner'; +import Select from 'react-select'; + +class SelectInput extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + value: props.value + }; + } + + cancel(event = null) { + this.props.onCancel(this.hasChanged, event); + } + + commit(event = null, value = null, force = false) { + this.props.onCommit(value || this.state.value, force ? true : this.hasChanged, event); + } + + focus() { + if(this.input != null) { + this.input.focus(); + } + } + + UNSAFE_componentWillReceiveProps({ value }) { + if (value !== this.props.value) { + this.setState({ value }); + } + } + + handleChange(value, ev) { + value = value !== null || (value === null && this.props.clearable) ? + value : this.props.value; + this.setState({ value }); + + if(this.props.onChange(value) || this.forceCommitOnNextChange) { + if(!ev) { + //@NOTE: this is using undocumeneted feature of react-selct v1, but see #131 + const source = typeof this.input.input.getInput === 'function' ? + this.input.input.getInput() : this.input.input; + ev = { + type: 'change', + currentTarget: source, + target: source + } + } + this.commit(ev, value, value !== this.props.value); + } + this.forceCommitOnNextChange = false; + } + + handleBlur(event) { + this.props.onBlur(event); + this.cancel(event); + if(this.props.autoBlur) { + this.forceCommitOnNextChange = true; + } + } + + handleFocus(event) { + this.props.onFocus(event); + } + + handleKeyDown(event) { + switch (event.key) { + case 'Escape': + this.cancel(event); + break; + default: + return; + } + } + + get hasChanged() { + return this.state.value !== this.props.value; + } + + get defaultSelectProps() { + return { + simpleValue: true, + clearable: false, + }; + } + + renderInput(userType, viewport) { + const { + options, + autoFocus, + className, + id, + placeholder, + tabIndex, + value, + } = this.props; + + const commonProps = { + disabled: this.props.isDisabled, + onBlur: this.handleBlur.bind(this), + onFocus: this.handleFocus.bind(this), + readOnly: this.props.isReadOnly, + ref: input => this.input = input, + required: this.props.isRequired, + }; + + if(userType === 'touch' || viewport.xxs || viewport.xs || viewport.sm) { + const props = { + ...commonProps, + onKeyDown: this.handleKeyDown.bind(this), + onChange: ev => this.handleChange(ev.target.value, ev), + autoFocus, id, placeholder, tabIndex, value + }; + return ( +
+ +
+ { (options.find(o => o.value === value) || options[0] || {}).label } +
+
+ + ); + } else { + const props = { + ...this.defaultSelectProps, + ...this.props, + ...commonProps, + onInputKeyDown: this.handleKeyDown.bind(this), + onChange: this.handleChange.bind(this), + }; + + return