mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-11-08 07:21:18 +08:00
Great breakdown of React changes here: https://github.com/facebook/react/blob/master/CHANGELOG.md#0140-october-7-2015 Due to deprecation warnings, I don't think this will break third-party extensions unless they were doing really bad things.
479 lines
14 KiB
JavaScript
479 lines
14 KiB
JavaScript
import _ from 'underscore';
|
|
import classNames from 'classnames';
|
|
import ScrollRegion from './scroll-region';
|
|
import KeyCommandsRegion from './key-commands-region';
|
|
import RetinaImg from './retina-img';
|
|
import React, {Component, PropTypes} from 'react';
|
|
import ReactDOM from 'react-dom';
|
|
|
|
/**
|
|
* Renders a list of items and renders controls to add/edit/remove items.
|
|
* It resembles OS X's default list component.
|
|
* An item can be a React Component, a string or number.
|
|
*
|
|
* EditableList handles:
|
|
* - Keyboard and mouse interactions to select an item
|
|
* - Input to create a new item when the add button is clicked
|
|
* - Callback to remove item when the remove button is clicked
|
|
* - Double click to edit item, or use an edit button icon
|
|
*
|
|
* @param {object} props - props for EditableList
|
|
* @param {string} props.className - CSS class to be applied to component
|
|
* @param {array} props.items - Items to be rendered by the list
|
|
* @param {function} props.itemContent - A function that returns a component
|
|
* or string for each item. To be editable, itemContent must be a string.
|
|
* If no function is provided, each value in `items` is coerced to a string.
|
|
* @param {(string|object)} props.selected - The selected item. This prop is
|
|
* optional unless uou want to control the selection externally.
|
|
* @param {boolean} props.showEditIcon - Determines wether to show edit icon
|
|
* button on selected items
|
|
* @param {object} props.createInputProps - Props object to be passed on to
|
|
* the create input element. However, keep in mind that these props can not
|
|
* override the default props that EditableList will pass to the input.
|
|
* @param {props.onCreateItem} props.onCreateItem
|
|
* @param {props.onDeleteItem} props.onDeleteItem
|
|
* @param {props.onSelectItem} props.onSelectItem
|
|
* @param {props.onReorderItem} props.onReorderItem
|
|
* @param {props.onItemEdited} props.onItemEdited
|
|
* @param {props.onItemCreated} props.onItemCreated
|
|
* @class EditableList
|
|
*/
|
|
class EditableList extends Component {
|
|
static displayName = 'EditableList';
|
|
|
|
/**
|
|
* If provided, this function will be called when the add button is clicked,
|
|
* and will prevent an input to be added at the end of the list
|
|
* @callback props.onCreateItem
|
|
*/
|
|
/**
|
|
* If provided, this function will be called when the delete button is clicked.
|
|
* @callback props.onDeleteItem
|
|
* @param {(Component|string|number)} selectedItem - The selected item.
|
|
* @param {number} idx - The selected item idx
|
|
*/
|
|
/**
|
|
* If provided, this function will be called when an item has been edited. This only
|
|
* applies to items that are not React Components.
|
|
* @callback props.onItemEdited
|
|
* @param {string} newValue - The new value for the item
|
|
* @param {(string|number)} originalValue - The original value for the item
|
|
* @param {number} idx - The index of the edited item
|
|
*/
|
|
/**
|
|
* If provided, this function will be called when an item is selected via click or arrow
|
|
* keys. If the selection is cleared, it will receive null.
|
|
* @callback props.onSelectItem
|
|
* @param {(Component|string|number)} selectedItem - The selected item or null
|
|
* when selection cleared
|
|
* @param {number} idx - The index of the selected item or null when selection
|
|
* cleared
|
|
*/
|
|
/**
|
|
* If provided, this function will be called when the user has entered a value to create
|
|
* a new item in the new item input. This function will be called when the
|
|
* user presses Enter or when the input is blurred.
|
|
* @callback props.onItemCreated
|
|
* @param {string} value - The value for the new item
|
|
*/
|
|
/**
|
|
* If provided, the user will be able to drag and drop items to re-arrange them
|
|
* within the list. Note that dragging between lists is not supported.
|
|
* @callback props.onReorderItem
|
|
* @param {Object} item - The item that was dragged
|
|
* @param {number} startIdx - The index the item was dragged from
|
|
* @param {number} endIdx - The new index of the item, assuming it was
|
|
already removed from startIdx.
|
|
*/
|
|
static propTypes = {
|
|
items: PropTypes.array.isRequired,
|
|
itemContent: PropTypes.func,
|
|
className: PropTypes.string,
|
|
showEditIcon: PropTypes.bool,
|
|
createInputProps: PropTypes.object,
|
|
onCreateItem: PropTypes.func,
|
|
onDeleteItem: PropTypes.func,
|
|
onReorderItem: PropTypes.func,
|
|
onItemEdited: PropTypes.func,
|
|
onItemCreated: PropTypes.func,
|
|
|
|
/* Optional, if you choose to control selection externally */
|
|
selected: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
|
|
onSelectItem: PropTypes.func,
|
|
};
|
|
|
|
static defaultProps = {
|
|
items: [],
|
|
itemContent: (item)=> item,
|
|
className: '',
|
|
createInputProps: {},
|
|
showEditIcon: false,
|
|
onDeleteItem: ()=> {},
|
|
onItemEdited: ()=> {},
|
|
onItemCreated: ()=> {},
|
|
};
|
|
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = {
|
|
dropInsertionIndex: -1,
|
|
editingIndex: -1,
|
|
creatingItem: false,
|
|
};
|
|
}
|
|
|
|
// Helpers
|
|
|
|
_createItem = (value)=> {
|
|
this._clearCreatingState(()=> {
|
|
if (value) {
|
|
this.props.onItemCreated(value);
|
|
}
|
|
});
|
|
};
|
|
|
|
_updateItem = (value, originalItem, idx)=> {
|
|
this._clearEditingState(()=> {
|
|
this.props.onItemEdited(value, originalItem, idx);
|
|
});
|
|
};
|
|
|
|
_getSelectedItem = ()=> {
|
|
if (this.props.onSelectItem) {
|
|
return this.props.selected;
|
|
}
|
|
return this.state.selected;
|
|
};
|
|
|
|
_selectItem = (item, idx)=> {
|
|
if (this.props.onSelectItem) {
|
|
this.props.onSelectItem(item, idx);
|
|
} else {
|
|
this.setState({selected: item});
|
|
}
|
|
};
|
|
|
|
_clearEditingState = (callback)=> {
|
|
this._setStateAndFocus({editingIndex: -1}, callback);
|
|
};
|
|
|
|
_clearCreatingState = (callback)=> {
|
|
this._setStateAndFocus({creatingItem: false}, callback);
|
|
};
|
|
|
|
_setStateAndFocus = (state, callback = ()=> {})=> {
|
|
this.setState(state, ()=> {
|
|
this._focusSelf();
|
|
callback();
|
|
});
|
|
};
|
|
|
|
_focusSelf = ()=> {
|
|
ReactDOM.findDOMNode(this).focus();
|
|
};
|
|
|
|
/**
|
|
* @private Scrolls to the dom node of the item at the provided index
|
|
* @param {number} idx - Index of item inside the list to scroll to
|
|
*/
|
|
_scrollTo = (idx)=> {
|
|
if (!idx) return;
|
|
const list = this.refs.itemsWrapper;
|
|
const nodes = ReactDOM.findDOMNode(list).querySelectorAll('.list-item');
|
|
list.scrollTo(nodes[idx]);
|
|
};
|
|
|
|
|
|
// Handlers
|
|
|
|
_onEditInputBlur = (event, item, idx)=> {
|
|
this._updateItem(event.target.value, item, idx);
|
|
};
|
|
|
|
_onEditInputFocus = (event)=> {
|
|
const input = event.target;
|
|
// Move cursor to the end of the input
|
|
input.selectionStart = input.selectionEnd = input.value.length;
|
|
};
|
|
|
|
_onEditInputKeyDown = (event, item, idx)=> {
|
|
event.stopPropagation();
|
|
if (_.includes(['Enter', 'Return'], event.key)) {
|
|
this._updateItem(event.target.value, item, idx);
|
|
} else if (event.key === 'Escape') {
|
|
this._clearEditingState();
|
|
}
|
|
};
|
|
|
|
_onCreateInputBlur = (event)=> {
|
|
this._createItem(event.target.value);
|
|
};
|
|
|
|
_onCreateInputKeyDown = (event)=> {
|
|
event.stopPropagation();
|
|
if (_.includes(['Enter', 'Return'], event.key)) {
|
|
this._createItem(event.target.value);
|
|
} else if (event.key === 'Escape') {
|
|
this._clearCreatingState();
|
|
}
|
|
};
|
|
|
|
_onItemClick = (event, item, idx)=> {
|
|
this._selectItem(item, idx);
|
|
};
|
|
|
|
_onItemEdit = (event, item, idx)=> {
|
|
this.setState({editingIndex: idx});
|
|
};
|
|
|
|
_listKeymapHandlers = ()=> {
|
|
const _shift = (dir) => {
|
|
const len = this.props.items.length;
|
|
const index = this.props.items.indexOf(this._getSelectedItem());
|
|
const newIndex = Math.min(len - 1, Math.max(0, index + dir));
|
|
if (index === newIndex) {
|
|
return;
|
|
}
|
|
this._scrollTo(newIndex);
|
|
this._selectItem(this.props.items[newIndex], newIndex);
|
|
};
|
|
return {
|
|
'core:previous-item': (event)=> {
|
|
event.stopPropagation();
|
|
_shift(-1);
|
|
},
|
|
'core:next-item': (event)=> {
|
|
event.stopPropagation();
|
|
_shift(1);
|
|
},
|
|
};
|
|
};
|
|
|
|
_onCreateItem = ()=> {
|
|
if (this.props.onCreateItem) {
|
|
this.props.onCreateItem();
|
|
} else {
|
|
this.setState({creatingItem: true});
|
|
}
|
|
};
|
|
|
|
_onDeleteItem = ()=> {
|
|
const selectedItem = this._getSelectedItem();
|
|
const index = this.props.items.indexOf(selectedItem);
|
|
|
|
if (selectedItem) {
|
|
// Move the selection 1 up after deleting
|
|
const len = this.props.items.length;
|
|
const newIndex = Math.min(len - 2, Math.max(0, index + 1));
|
|
this.props.onDeleteItem(selectedItem, index);
|
|
|
|
if (this.props.items[newIndex]) {
|
|
this._selectItem(this.props.items[newIndex], newIndex);
|
|
}
|
|
}
|
|
};
|
|
|
|
_onItemDragStart = (event)=> {
|
|
if (!this.props.onReorderItem) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
const row = event.target.closest('[data-item-idx]') || event.target;
|
|
const wrapperId = ReactDOM.findDOMNode(this.refs.itemsWrapper).dataset.reactid;
|
|
|
|
if (!row.dataset.itemIdx) {
|
|
return;
|
|
}
|
|
|
|
if (row.dataset.itemIdx / 1 === this.state.editingIndex / 1) {
|
|
// dragging the row currently being edited makes text selection impossible
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
event.dataTransfer.setData('editablelist-index', row.dataset.itemIdx);
|
|
event.dataTransfer.setData('editablelist-reactid', wrapperId);
|
|
event.dataTransfer.effectAllowed = "move";
|
|
};
|
|
|
|
_onDragOver = (event)=> {
|
|
const wrapperNode = ReactDOM.findDOMNode(this.refs.itemsWrapper);
|
|
const originWrapperId = event.dataTransfer.getData('editablelist-reactid')
|
|
const originSameList = (originWrapperId === wrapperNode.dataset.reactid);
|
|
let dropInsertionIndex = 0;
|
|
|
|
if ((event.currentTarget === wrapperNode) && originSameList) {
|
|
const itemNodes = wrapperNode.querySelectorAll('[data-item-idx]')
|
|
for (let i = 0; i < itemNodes.length; i ++) {
|
|
const itemNode = itemNodes[i];
|
|
const rect = itemNode.getBoundingClientRect();
|
|
if (event.clientY > rect.top + (rect.height / 2)) {
|
|
dropInsertionIndex = itemNode.dataset.itemIdx / 1 + 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
dropInsertionIndex = -1;
|
|
}
|
|
|
|
if (this.state.dropInsertionIndex !== dropInsertionIndex) {
|
|
this.setState({dropInsertionIndex: dropInsertionIndex});
|
|
}
|
|
};
|
|
|
|
_onDragLeave = ()=> {
|
|
this.setState({dropInsertionIndex: -1});
|
|
};
|
|
|
|
_onDrop = (event)=> {
|
|
if (this.state.dropInsertionIndex !== -1) {
|
|
const startIdx = event.dataTransfer.getData('editablelist-index');
|
|
if (startIdx && (this.state.dropInsertionIndex !== startIdx)) {
|
|
const item = this.props.items[startIdx];
|
|
|
|
let endIdx = this.state.dropInsertionIndex;
|
|
if (endIdx > startIdx) {
|
|
endIdx -= 1
|
|
}
|
|
|
|
this.props.onReorderItem(item, startIdx, endIdx);
|
|
this.setState({dropInsertionIndex: -1});
|
|
}
|
|
}
|
|
};
|
|
|
|
// Renderers
|
|
|
|
_renderEditInput = (item, itemContent, idx, handlers = {})=> {
|
|
const onInputBlur = handlers.onInputBlur || this._onEditInputBlur;
|
|
const onInputFocus = handlers.onInputFocus || this._onEditInputFocus;
|
|
const onInputKeyDown = handlers.onInputKeyDown || this._onEditInputKeyDown;
|
|
|
|
return (
|
|
<input
|
|
autoFocus
|
|
type="text"
|
|
placeholder={itemContent}
|
|
defaultValue={itemContent}
|
|
onBlur={_.partial(onInputBlur, _, item, idx)}
|
|
onFocus={onInputFocus}
|
|
onKeyDown={_.partial(onInputKeyDown, _, item, idx)} />
|
|
);
|
|
};
|
|
|
|
/**
|
|
* @private Will render the create input with the provided input props.
|
|
* Provided props will be overriden with the props that EditableList needs to
|
|
* pass to the input.
|
|
*/
|
|
_renderCreateInput = ()=> {
|
|
const props = _.extend(this.props.createInputProps, {
|
|
autoFocus: true,
|
|
type: 'text',
|
|
onBlur: this._onCreateInputBlur,
|
|
onKeyDown: this._onCreateInputKeyDown,
|
|
});
|
|
|
|
return (
|
|
<div className="create-item-input" key="create-item-input">
|
|
<input {...props}/>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// handlers object for testing
|
|
_renderItem = (item, idx, {editingIndex} = this.state, handlers = {})=> {
|
|
const onClick = handlers.onClick || this._onItemClick;
|
|
const onEdit = handlers.onEdit || this._onItemEdit;
|
|
|
|
let itemContent = this.props.itemContent(item);
|
|
const itemIsEditable = !React.isValidElement(itemContent);
|
|
|
|
const classes = classNames({
|
|
'list-item': true,
|
|
'selected': item === this._getSelectedItem(),
|
|
'editing': idx === editingIndex,
|
|
'editable-item': itemIsEditable,
|
|
'with-edit-icon': this.props.showEditIcon && editingIndex !== idx,
|
|
});
|
|
|
|
if ((editingIndex === idx) && itemIsEditable) {
|
|
itemContent = this._renderEditInput(item, itemContent, idx, handlers);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={classes}
|
|
key={idx}
|
|
data-item-idx={idx}
|
|
draggable
|
|
onDragStart={this._onItemDragStart}
|
|
onClick={_.partial(onClick, _, item, idx)}
|
|
onDoubleClick={_.partial(onEdit, _, item, idx)}>
|
|
{itemContent}
|
|
<RetinaImg
|
|
className="edit-icon"
|
|
name="edit-icon.png"
|
|
title="Edit Item"
|
|
mode={RetinaImg.Mode.ContentIsMask}
|
|
onClick={_.partial(onEdit, _, item, idx)} />
|
|
</div>
|
|
);
|
|
};
|
|
|
|
_renderButtons = ()=> {
|
|
const deleteClasses = classNames({
|
|
'btn-editable-list': true,
|
|
'btn-disabled': !this._getSelectedItem(),
|
|
});
|
|
return (
|
|
<div className="buttons-wrapper">
|
|
<div className="btn-editable-list" onClick={this._onCreateItem}>
|
|
<span>+</span>
|
|
</div>
|
|
<div className={deleteClasses} onClick={this._onDeleteItem}>
|
|
<span>—</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
_renderDropInsertion = ()=> {
|
|
return (
|
|
<div className="insertion-point"><div></div></div>
|
|
)
|
|
};
|
|
|
|
render() {
|
|
let items = this.props.items.map( (item, idx)=> this._renderItem(item, idx));
|
|
if (this.state.creatingItem === true) {
|
|
items = items.concat(this._renderCreateInput());
|
|
}
|
|
|
|
if (this.state.dropInsertionIndex !== -1) {
|
|
items.splice(this.state.dropInsertionIndex, 0, this._renderDropInsertion());
|
|
}
|
|
|
|
return (
|
|
<KeyCommandsRegion
|
|
tabIndex="1"
|
|
localHandlers={this._listKeymapHandlers()}
|
|
className={`nylas-editable-list ${this.props.className}`}>
|
|
<ScrollRegion
|
|
className="items-wrapper"
|
|
ref="itemsWrapper"
|
|
onDragOver={this._onDragOver}
|
|
onDragLeave={this._onDragLeave}
|
|
onDrop={this._onDrop}>
|
|
{items}
|
|
</ScrollRegion>
|
|
{this._renderButtons()}
|
|
</KeyCommandsRegion>
|
|
);
|
|
}
|
|
|
|
}
|
|
|
|
export default EditableList;
|