mirror of
https://github.com/Foundry376/Mailspring.git
synced 2025-10-01 00:46:54 +08:00
957 lines
28 KiB
JavaScript
957 lines
28 KiB
JavaScript
import React from 'react'
|
|
import ReactDOM from 'react-dom'
|
|
import classNames from 'classnames'
|
|
import _ from 'underscore'
|
|
import {remote} from 'electron';
|
|
import {Utils, RegExpUtils} from 'nylas-exports'
|
|
import {Menu} from 'nylas-component-kit';
|
|
|
|
import RetinaImg from './retina-img';
|
|
import KeyCommandsRegion from './key-commands-region';
|
|
|
|
class SizeToFitInput extends React.Component {
|
|
static propTypes = {
|
|
value: React.PropTypes.string,
|
|
};
|
|
|
|
constructor(props) {
|
|
super(props)
|
|
this.state = {};
|
|
}
|
|
|
|
componentDidMount() {
|
|
this._sizeToFit()
|
|
}
|
|
|
|
componentDidUpdate() {
|
|
this._sizeToFit()
|
|
}
|
|
|
|
_sizeToFit() {
|
|
if (this.props.value.length === 0) {
|
|
return;
|
|
}
|
|
// Measure the width of the text in the input and
|
|
// resize the input field to fit.
|
|
const inputEl = ReactDOM.findDOMNode(this.refs.input)
|
|
const measureEl = ReactDOM.findDOMNode(this.refs.measure)
|
|
measureEl.innerText = inputEl.value;
|
|
measureEl.style.top = `${inputEl.offsetTop}px`;
|
|
measureEl.style.left = `${inputEl.offsetLeft}px`;
|
|
// The 10px comes from the 7.5px left padding and 2.5px more of
|
|
// breathing room.
|
|
inputEl.style.width = `${measureEl.offsetWidth + 10}px`;
|
|
}
|
|
|
|
select() {
|
|
ReactDOM.findDOMNode(this.refs.input).select();
|
|
}
|
|
|
|
selectionRange() {
|
|
const inputEl = ReactDOM.findDOMNode(this.refs.input);
|
|
return {
|
|
start: inputEl.selectionStart,
|
|
end: inputEl.selectionEnd,
|
|
};
|
|
}
|
|
|
|
focus() {
|
|
ReactDOM.findDOMNode(this.refs.input).focus();
|
|
}
|
|
|
|
render() {
|
|
return (
|
|
<span>
|
|
<span ref="measure" style={{visibility: 'hidden', position: 'absolute'}} />
|
|
<input ref="input" type="text" style={{width: 1}} {...this.props} />
|
|
</span>
|
|
);
|
|
}
|
|
}
|
|
|
|
class Token extends React.Component {
|
|
static displayName = "Token";
|
|
|
|
static propTypes = {
|
|
className: React.PropTypes.string,
|
|
selected: React.PropTypes.bool,
|
|
valid: React.PropTypes.bool,
|
|
item: React.PropTypes.object,
|
|
onClick: React.PropTypes.func.isRequired,
|
|
onDragStart: React.PropTypes.func.isRequired,
|
|
onEdited: React.PropTypes.func,
|
|
onAction: React.PropTypes.func,
|
|
disabled: React.PropTypes.bool,
|
|
onEditMotion: React.PropTypes.func,
|
|
}
|
|
|
|
static defaultProps = {
|
|
className: '',
|
|
}
|
|
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = {
|
|
editing: false,
|
|
editingValue: this.props.item.toString(),
|
|
};
|
|
}
|
|
|
|
componentWillReceiveProps(props) {
|
|
// never override the text the user is editing if they're looking at it
|
|
if (this.state.editing) {
|
|
return;
|
|
}
|
|
this.setState({editingValue: props.item.toString()});
|
|
}
|
|
|
|
componentDidUpdate(prevProps, prevState) {
|
|
if (this.state.editing && !prevState.editing) {
|
|
this.refs.input.select();
|
|
}
|
|
}
|
|
|
|
_renderEditing() {
|
|
return (
|
|
<SizeToFitInput
|
|
ref="input"
|
|
className="token-editing-input"
|
|
spellCheck="false"
|
|
value={this.state.editingValue}
|
|
onKeyDown={this._onEditKeydown}
|
|
onBlur={this._onEditFinished}
|
|
onChange={(event) => this.setState({editingValue: event.target.value})}
|
|
/>
|
|
);
|
|
}
|
|
|
|
_renderViewing() {
|
|
const classes = classNames({
|
|
token: true,
|
|
disabled: this.props.disabled,
|
|
dragging: this.state.dragging,
|
|
invalid: !this.props.valid,
|
|
selected: this.props.selected,
|
|
});
|
|
|
|
let actionButton = null;
|
|
if (this.props.onAction && !this.props.disabled) {
|
|
actionButton = (
|
|
<button type="button" className="action" onClick={this._onAction} tabIndex={-1}>
|
|
<RetinaImg mode={RetinaImg.Mode.ContentIsMask} name="composer-caret.png" />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={`${classes} ${this.props.className}`}
|
|
onDragStart={this._onDragStart}
|
|
onDragEnd={this._onDragEnd}
|
|
draggable={!this.props.disabled}
|
|
onDoubleClick={this._onDoubleClick}
|
|
onClick={this._onClick}
|
|
>
|
|
{actionButton}
|
|
{this.props.children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
_onDragStart = (event) => {
|
|
if (this.props.disabled) return;
|
|
this.props.onDragStart(event, this.props.item);
|
|
this.setState({dragging: true});
|
|
}
|
|
|
|
_onDragEnd = () => {
|
|
if (this.props.disabled) return;
|
|
this.setState({dragging: false})
|
|
}
|
|
|
|
_onClick = (event) => {
|
|
if (this.props.disabled) return;
|
|
this.props.onClick(event, this.props.item);
|
|
}
|
|
|
|
_onDoubleClick = () => {
|
|
if (this.props.disabled) return;
|
|
if (this.props.onEditMotion) {
|
|
this.props.onEditMotion(this.props.item);
|
|
}
|
|
if (this.props.onEdited) {
|
|
this.setState({editing: true});
|
|
}
|
|
}
|
|
|
|
_onEditKeydown = (event) => {
|
|
if (this.props.disabled) return;
|
|
if (event.key === "Enter" && this.props.selected && this.props.onEditMotion) {
|
|
this.props.onEditMotion(this.props.item);
|
|
}
|
|
if (['Escape', 'Enter'].includes(event.key)) {
|
|
this._onEditFinished();
|
|
}
|
|
}
|
|
|
|
_onEditFinished = () => {
|
|
if (this.props.disabled) return;
|
|
if (this.props.onEdited) {
|
|
this.props.onEdited(this.props.item, this.state.editingValue);
|
|
}
|
|
this.setState({editing: false});
|
|
}
|
|
|
|
_onAction = () => {
|
|
if (this.props.disabled) return;
|
|
this.props.onAction(this.props.item);
|
|
event.preventDefault();
|
|
}
|
|
|
|
render() {
|
|
return this.state.editing ? this._renderEditing() : this._renderViewing();
|
|
}
|
|
}
|
|
|
|
/*
|
|
Public: The TokenizingTextField component displays a list of options as you type and converts them into stylable tokens.
|
|
|
|
It wraps the Menu component, which takes care of the typing and keyboard
|
|
interactions.
|
|
|
|
See documentation on the propTypes for usage info.
|
|
|
|
Section: Component Kit
|
|
*/
|
|
export default class TokenizingTextField extends React.Component {
|
|
static displayName = "TokenizingTextField";
|
|
|
|
static containerRequired = false;
|
|
|
|
static Token = Token;
|
|
|
|
static propTypes = {
|
|
className: React.PropTypes.string,
|
|
|
|
disabled: React.PropTypes.bool,
|
|
|
|
placeholder: React.PropTypes.node,
|
|
|
|
// An array of current tokens.
|
|
//
|
|
// A token is usually an object type like a `Contact`. The set of
|
|
// tokens is stored as a prop instead of `state`. This means that when
|
|
// the set of tokens needs to be changed, it is the parent's
|
|
// responsibility to make that change.
|
|
tokens: React.PropTypes.arrayOf(React.PropTypes.object),
|
|
|
|
// The maximum number of tokens allowed. When null (the default) and
|
|
// unlimited number of tokens may be given
|
|
maxTokens: React.PropTypes.number,
|
|
|
|
// A string to pre-fill the input with when the tokens are empty.
|
|
defaultValue: React.PropTypes.string,
|
|
|
|
// A function that, given an object used for tokens, returns a unique
|
|
// id (key) for that object.
|
|
//
|
|
// This is necessary for React to assign each of the subitems and
|
|
// unique key.
|
|
tokenKey: React.PropTypes.func.isRequired,
|
|
|
|
// A function that, given a token, returns true if the token is valid
|
|
// and false if the token is invalid. Useful if your implementation of
|
|
// onAdd allows invalid tokens to be added to the field (ie malformed
|
|
// email addresses.) Optional.
|
|
//
|
|
tokenIsValid: React.PropTypes.func,
|
|
|
|
// What each token looks like
|
|
//
|
|
// A function that is passed an object and should return React elements
|
|
// to display that individual token.
|
|
tokenRenderer: React.PropTypes.func.isRequired,
|
|
|
|
tokenClassNames: React.PropTypes.func,
|
|
|
|
// The function responsible for providing a list of possible options
|
|
// given the current input.
|
|
//
|
|
// It takes the current input as a value and should return an array of
|
|
// candidate objects. These objects must be the same type as are passed
|
|
// to the `tokens` prop.
|
|
//
|
|
// The function may either directly return tokens, or may return a
|
|
// Promise, that resolves with the requested tokens
|
|
onRequestCompletions: React.PropTypes.func.isRequired,
|
|
|
|
// What each suggestion looks like.
|
|
//
|
|
// This is passed through to the Menu component's `itemContent` prop.
|
|
// See components/menu.cjsx for more info.
|
|
completionNode: React.PropTypes.func.isRequired,
|
|
|
|
// Gets called when we we're ready to add whatever it is we're
|
|
// completing
|
|
//
|
|
// It's either passed an array of objects (the same ones used to
|
|
// render tokens)
|
|
//
|
|
// OR
|
|
//
|
|
// It's passed the string currently in the input field. The string case
|
|
// happens on paste and blur.
|
|
//
|
|
// The function doesn't need to return anything, but it is generally
|
|
// responible for mutating the parent's state in a way that eventually
|
|
// updates this component's `tokens` prop.
|
|
onAdd: React.PropTypes.func.isRequired,
|
|
|
|
// This gets fired when people try and submit a query with a break
|
|
// character (tab, comma, semicolon, etc). It lets us the caller
|
|
// determine how to best deal with available options.
|
|
|
|
// If this method is not implemented we'll pick the first available
|
|
// option in the completions
|
|
onInputTrySubmit: React.PropTypes.func,
|
|
|
|
// If implemented lets the caller determine when to cut a token based
|
|
// on the current input value and the current keydown.
|
|
shouldBreakOnKeydown: React.PropTypes.func,
|
|
|
|
// Gets called when we remove a token
|
|
//
|
|
// It's passed an array of objects (the same ones used to render
|
|
// tokens)
|
|
//
|
|
// The function doesn't need to return anything, but it is generally
|
|
// responible for mutating the parent's state in a way that eventually
|
|
// updates this component's `tokens` prop.
|
|
onRemove: React.PropTypes.func.isRequired,
|
|
|
|
// Gets called when an existing token is double-clicked and edited.
|
|
// Do not provide this method if you want to disable editing.
|
|
//
|
|
// It's passed a token index, and the new text typed in that location.
|
|
//
|
|
// The function doesn't need to return anything, but it is generally
|
|
// responible for mutating the parent's state in a way that eventually
|
|
// updates this component's `tokens` prop.
|
|
onEdit: React.PropTypes.func,
|
|
|
|
// This is slightly different than onEdit. onEditMotion gets fired if
|
|
// the user does an editing-like action on a Token. Double clicking,
|
|
// etc. This is usefulf for when you don't want the text of the tokens
|
|
// themselves to be editable, but want to perform some action when the
|
|
// tokens are double clicked.
|
|
onEditMotion: React.PropTypes.func,
|
|
|
|
// Called when we remove and there's nothing left to remove
|
|
onEmptied: React.PropTypes.func,
|
|
|
|
// Called when the secondary action of the token gets invoked.
|
|
onTokenAction: React.PropTypes.oneOfType([
|
|
React.PropTypes.func,
|
|
React.PropTypes.bool,
|
|
]),
|
|
|
|
// Called when the input is focused
|
|
onFocus: React.PropTypes.func,
|
|
|
|
// A Prompt used in the head of the menu
|
|
menuPrompt: React.PropTypes.string,
|
|
|
|
// A classSet hash applied to the Menu item
|
|
menuClassSet: React.PropTypes.object,
|
|
|
|
tabIndex: React.PropTypes.number,
|
|
};
|
|
|
|
static defaultProps = {
|
|
tokens: [],
|
|
className: '',
|
|
defaultValue: '',
|
|
tokenClassNames: () => '',
|
|
}
|
|
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = {
|
|
inputValue: props.defaultValue || "",
|
|
completions: [],
|
|
selectedKeys: [],
|
|
}
|
|
}
|
|
|
|
componentDidMount() {
|
|
this._mounted = true;
|
|
if (this.props.tokens.length === 0) {
|
|
if (this.state.inputValue && this.state.inputValue.length > 0) {
|
|
this._refreshCompletions(this.state.inputValue);
|
|
}
|
|
}
|
|
}
|
|
|
|
componentWillReceiveProps(newProps) {
|
|
if (this.props.tokens.length === 0 && this.state.inputValue.length === 0) {
|
|
const newDefaultValue = newProps.defaultValue || ""
|
|
this.setState({inputValue: newDefaultValue});
|
|
if (newDefaultValue.length > 0) {
|
|
this._refreshCompletions(newDefaultValue);
|
|
}
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this._mounted = false;
|
|
}
|
|
|
|
// Maintaining Input State
|
|
|
|
_onClick = (event) => {
|
|
// Don't focus if the focus is already on an input within our field,
|
|
// like an editable token's input
|
|
if (event.target.tagName === 'INPUT' && ReactDOM.findDOMNode(this).contains(event.target)) {
|
|
return;
|
|
}
|
|
this.focus();
|
|
}
|
|
|
|
_onDrop = (event) => {
|
|
if (!event.dataTransfer.types.includes('nylas-token-items')) {
|
|
return;
|
|
}
|
|
|
|
const data = event.dataTransfer.getData('nylas-token-items');
|
|
this._onAddItemsFromJSON(data);
|
|
}
|
|
|
|
_onAddItemsFromJSON = (json) => {
|
|
let items = null;
|
|
|
|
try {
|
|
items = JSON.parse(json).map(Utils.convertToModel);
|
|
} catch (err) {
|
|
console.error(err)
|
|
items = null;
|
|
}
|
|
|
|
if (items) {
|
|
this._addTokens(items);
|
|
}
|
|
}
|
|
|
|
_onInputFocused = ({noCompletions} = {}) => {
|
|
this.setState({focus: true});
|
|
if (this.props.onFocus) {
|
|
this.props.onFocus();
|
|
}
|
|
if (!noCompletions) {
|
|
this._refreshCompletions();
|
|
}
|
|
}
|
|
|
|
_onInputKeydown = (event) => {
|
|
if (["Backspace", "Delete"].includes(event.key)) {
|
|
this._removeTokens(this._selectedTokens());
|
|
} else if (["Escape"].includes(event.key)) {
|
|
this._refreshCompletions("", {clear: true})
|
|
} else if (["Tab", "Enter"].includes(event.key)) {
|
|
this._onInputTrySubmit(event);
|
|
} else if (["ArrowLeft", "ArrowRight"].includes(event.key)) {
|
|
const delta = event.key === 'ArrowLeft' ? -1 : 1;
|
|
const {start} = this.refs.input.selectionRange();
|
|
|
|
// with tokens selected, arrow keys manipulate the selection
|
|
if (this.state.selectedKeys.length > 0) {
|
|
this._onShiftSelection(delta, event);
|
|
event.preventDefault();
|
|
// without tokens selected, left arrow key at position 0 selects item
|
|
} else if ((delta === -1) && (start === 0)) {
|
|
this._onShiftSelection(delta, event);
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
if (this.props.shouldBreakOnKeydown) {
|
|
if (this.props.shouldBreakOnKeydown(event)) {
|
|
event.preventDefault();
|
|
this._onInputTrySubmit(event);
|
|
}
|
|
} else if (event.key === ',') { // comma
|
|
event.preventDefault();
|
|
this._onInputTrySubmit(event);
|
|
}
|
|
}
|
|
|
|
_onSelectAll = () => {
|
|
const {tokens, tokenKey} = this.props;
|
|
this.setState({selectedKeys: tokens.map(t => tokenKey(t))});
|
|
}
|
|
|
|
_onSelectNone = () => {
|
|
this.setState({selectedKeys: []});
|
|
}
|
|
|
|
_onShiftSelection = (delta, event) => {
|
|
const multiselectModifierPresent = event.shiftKey || event.metaKey;
|
|
const {tokenKey, tokens} = this.props;
|
|
const {selectedKeys} = this.state;
|
|
|
|
// select the last token on left arrow press if no tokens are selected
|
|
if (selectedKeys.length === 0) {
|
|
if (delta === -1) {
|
|
const key = tokenKey(_.last(tokens));
|
|
this.setState({selectedKeys: [key]});
|
|
}
|
|
return;
|
|
}
|
|
|
|
const headKey = _.last(selectedKeys);
|
|
const headIdx = tokens.map(t => tokenKey(t)).indexOf(headKey)
|
|
const nextToken = tokens[headIdx + delta];
|
|
|
|
if (multiselectModifierPresent) {
|
|
if (!nextToken) { return; }
|
|
const nextKey = tokenKey(nextToken);
|
|
const beneathHeadKey = selectedKeys[selectedKeys.length - 2];
|
|
|
|
if (nextKey === beneathHeadKey) {
|
|
// If the user is "walking back" their selection, deselect the head item
|
|
// Ex: Shift+Left, Shift+Right undoes prev. Shift+left.
|
|
this.setState({
|
|
selectedKeys: selectedKeys.filter(t => t !== headKey),
|
|
});
|
|
} else {
|
|
// If the user is expanding their selection, always filter then add to
|
|
// ensure the last item in the array is the most recently selected.
|
|
this.setState({
|
|
selectedKeys: selectedKeys.filter(t => t !== nextKey).concat([nextKey]),
|
|
});
|
|
}
|
|
} else {
|
|
this.setState({
|
|
selectedKeys: nextToken ? [tokenKey(nextToken)] : [],
|
|
});
|
|
}
|
|
}
|
|
|
|
_onInputTrySubmit = (event) => {
|
|
if ((this.state.inputValue || "").trim().length === 0) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
|
|
const {inputValue, completions} = this.state;
|
|
|
|
// default behavior
|
|
let token = null;
|
|
if (completions.length > 0) {
|
|
token = this.refs.completions.getSelectedItem() || completions[0];
|
|
}
|
|
|
|
// allow our container to override behavior
|
|
if (this.props.onInputTrySubmit) {
|
|
token = this.props.onInputTrySubmit(inputValue, completions, token);
|
|
if (typeof token === 'string') {
|
|
this._addInputValue(token, {skipNameLookup: true});
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (token) {
|
|
this._addToken(token);
|
|
} else {
|
|
this._addInputValue()
|
|
}
|
|
}
|
|
|
|
_onInputChanged = (event) => {
|
|
const val = event.target.value.trimLeft()
|
|
this.setState({
|
|
selectedKeys: [],
|
|
inputValue: val,
|
|
});
|
|
|
|
this._refreshCompletions(val);
|
|
}
|
|
|
|
_onInputBlurred = (event) => {
|
|
// Not having a relatedTarget can happen when the whole app blurs. When
|
|
// this happens we want to leave the field as-is
|
|
if (!event.relatedTarget) {
|
|
return;
|
|
}
|
|
|
|
if (event.relatedTarget === ReactDOM.findDOMNode(this)) {
|
|
return;
|
|
}
|
|
|
|
this._addInputValue();
|
|
this._refreshCompletions("", {clear: true})
|
|
this.setState({
|
|
selectedKeys: [],
|
|
focus: false,
|
|
});
|
|
}
|
|
|
|
_clearInput() {
|
|
this.setState({inputValue: ""});
|
|
this._refreshCompletions("", {clear: true});
|
|
}
|
|
|
|
focus() {
|
|
this.refs.input.focus();
|
|
}
|
|
|
|
// Managing Tokens
|
|
|
|
_addInputValue = (input = this.state.inputValue, options = {}) => {
|
|
if (this._atMaxTokens()) {
|
|
return;
|
|
}
|
|
if (input.length === 0) {
|
|
return;
|
|
}
|
|
this.props.onAdd(input, options);
|
|
this._clearInput();
|
|
}
|
|
|
|
_onClickToken = (event, token) => {
|
|
const {tokenKey, tokens} = this.props;
|
|
let {selectedKeys} = this.state;
|
|
|
|
if (event.shiftKey) {
|
|
// Expand selection from the currently selected item to the one the user
|
|
// has clicked. We must walk the items in order so selectedKeys is
|
|
// an ordered list.
|
|
let headKey = _.last(selectedKeys);
|
|
let headIdx = tokens.map(t => tokenKey(t)).indexOf(headKey);
|
|
const clickedIdx = tokens.indexOf(token);
|
|
|
|
if ((clickedIdx === -1) || (clickedIdx === headIdx)) {
|
|
return;
|
|
}
|
|
|
|
const step = Math.max(-1, Math.min(1, clickedIdx - headIdx));
|
|
|
|
do {
|
|
headIdx += step;
|
|
headKey = tokenKey(tokens[headIdx]);
|
|
selectedKeys = selectedKeys.filter(t => t !== headKey).concat([headKey]);
|
|
} while (headIdx !== clickedIdx);
|
|
} else if (event.metaKey) {
|
|
// Expand the selection to include the clicked item, without selecting
|
|
// the items in between. If the item is already selected, deselect it.
|
|
const key = tokenKey(token);
|
|
if (selectedKeys.includes(key)) {
|
|
selectedKeys = selectedKeys.filter(t => t !== key)
|
|
} else {
|
|
selectedKeys = selectedKeys.concat([key]);
|
|
}
|
|
} else {
|
|
// Clear the selection and select just the new token
|
|
selectedKeys = [tokenKey(token)];
|
|
}
|
|
|
|
this.setState({selectedKeys})
|
|
}
|
|
|
|
_onDragToken = (event, token) => {
|
|
let tokens = this._selectedTokens()
|
|
if (tokens.length === 0) {
|
|
tokens = [token];
|
|
}
|
|
const json = JSON.stringify(tokens);
|
|
event.dataTransfer.setData('nylas-token-items', json);
|
|
event.dataTransfer.setData('text/plain', tokens.map(t => t.toString()).join(', '));
|
|
event.dataTransfer.dropEffect = "move";
|
|
event.dataTransfer.effectAllowed = "move";
|
|
}
|
|
|
|
_selectedTokens() {
|
|
return this.props.tokens.filter((t) =>
|
|
this.state.selectedKeys.includes(this.props.tokenKey(t))
|
|
);
|
|
}
|
|
|
|
_addToken = (token) => {
|
|
if (!token) { return; }
|
|
this._addTokens([token]);
|
|
}
|
|
|
|
_addTokens = (tokens) => {
|
|
this.props.onAdd(tokens);
|
|
// It's possible for `_addTokens` to be fired by the menu
|
|
// asynchronously. When the tokenizing text field is in a popover it's
|
|
// possible for it to be unmounted before the add tokens fires.
|
|
if (this._mounted) {
|
|
this._clearInput();
|
|
this.focus();
|
|
}
|
|
}
|
|
|
|
_removeTokens = (tokensToDelete) => {
|
|
const {inputValue, selectedKeys} = this.state;
|
|
const {onEmptied, onRemove, tokens, tokenKey} = this.props;
|
|
|
|
if ((inputValue.trim().length === 0) && (tokens.length === 0) && onEmptied) {
|
|
onEmptied();
|
|
}
|
|
|
|
if (tokensToDelete.length) {
|
|
const tokensToDeleteKeys = tokensToDelete.map(t => tokenKey(t));
|
|
onRemove(tokensToDelete);
|
|
this.setState({
|
|
selectedKeys: selectedKeys.filter(k => !tokensToDeleteKeys.includes(k)),
|
|
});
|
|
} else {
|
|
const lastToken = _.last(tokens);
|
|
if (lastToken) {
|
|
const lastTokenKey = tokenKey(lastToken);
|
|
this.setState({
|
|
selectedKeys: selectedKeys.filter(k => k !== lastTokenKey).concat([lastTokenKey]),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
_showDefaultTokenMenu = (token) => {
|
|
const menu = new remote.Menu()
|
|
menu.append(new remote.MenuItem({
|
|
click: () => this._removeTokens([token]),
|
|
label: 'Remove',
|
|
}));
|
|
|
|
if (this.props.onEditMotion) {
|
|
menu.append(new remote.MenuItem({
|
|
label: 'Edit',
|
|
click: () => this.props.onEditMotion(token),
|
|
}))
|
|
}
|
|
menu.popup(remote.getCurrentWindow());
|
|
}
|
|
|
|
// Copy and Paste
|
|
|
|
_onCut = (event) => {
|
|
if (this.state.selectedKeys.length) {
|
|
this._onAttachToClipboard(event);
|
|
// clear the tokens which were selected
|
|
this._removeTokens(this._selectedTokens())
|
|
// clear the text in the input if some was selected
|
|
document.execCommand('delete');
|
|
}
|
|
}
|
|
|
|
_onCopy = (event) => {
|
|
if (this.state.selectedKeys.length) {
|
|
this._onAttachToClipboard(event);
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
_onAttachToClipboard = (event) => {
|
|
const text = this.state.selectedKeys.join(', ')
|
|
if (event.clipboardData) {
|
|
const json = JSON.stringify(this._selectedTokens());
|
|
event.clipboardData.setData('text/plain', text);
|
|
event.clipboardData.setData('nylas-token-items', json);
|
|
|
|
const range = this.refs.input.selectionRange();
|
|
if (range.end > 0) {
|
|
const inputSelection = this.state.inputValue.substr(range.start, range.end - range.start);
|
|
event.clipboardData.setData('nylas-token-input', inputSelection);
|
|
} else {
|
|
event.clipboardData.setData('nylas-token-input', 'null');
|
|
}
|
|
}
|
|
event.preventDefault()
|
|
}
|
|
|
|
_onPaste = (event) => {
|
|
const json = event.clipboardData.getData('nylas-token-items');
|
|
const inputValue = event.clipboardData.getData('nylas-token-input');
|
|
if (json) {
|
|
this._onAddItemsFromJSON(json)
|
|
if (inputValue && inputValue !== 'null') {
|
|
this.setState({inputValue})
|
|
}
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
|
|
const text = event.clipboardData.getData('text/plain');
|
|
if (text) {
|
|
const newInputValue = this.state.inputValue + text
|
|
if (RegExpUtils.emailRegex().test(newInputValue)) {
|
|
this._addInputValue(newInputValue, {skipNameLookup: true});
|
|
event.preventDefault();
|
|
} else {
|
|
this._refreshCompletions(newInputValue);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Managing Suggestions
|
|
|
|
// Asks `this.props.onRequestCompletions` for new completions given the
|
|
// current inputValue. Since `onRequestCompletions` can be asynchronous,
|
|
// this function will handle calling `setState` on `completions` when
|
|
// `onRequestCompletions` returns.
|
|
_refreshCompletions = (val = this.state.inputValue, {clear} = {}) => {
|
|
const usedKeys = this.props.tokens.map(this.props.tokenKey);
|
|
const removeUsedTokens = (tokens) => {
|
|
return tokens.filter((t) => !usedKeys.includes(this.props.tokenKey(t)));
|
|
}
|
|
|
|
const tokensOrPromise = this.props.onRequestCompletions(val, {clear});
|
|
|
|
if (_.isArray(tokensOrPromise)) {
|
|
this.setState({completions: removeUsedTokens(tokensOrPromise)})
|
|
} else if (tokensOrPromise instanceof Promise) {
|
|
tokensOrPromise.then((tokens) => {
|
|
if (!this._mounted) { return; }
|
|
this.setState({completions: removeUsedTokens(tokens)});
|
|
});
|
|
} else {
|
|
console.warn("onRequestCompletions returned an invalid type. It must return an Array of tokens or a Promise that resolves to an array of tokens");
|
|
this.setState({completions: []});
|
|
}
|
|
}
|
|
|
|
// Rendering
|
|
|
|
_inputComponent() {
|
|
const props = {
|
|
onCopy: this._onCopy,
|
|
onCut: this._onCut,
|
|
onPaste: this._onPaste,
|
|
onKeyDown: this._onInputKeydown,
|
|
onBlur: this._onInputBlurred,
|
|
onFocus: this._onInputFocused,
|
|
onChange: this._onInputChanged,
|
|
disabled: this.props.disabled,
|
|
tabIndex: this.props.tabIndex || 0,
|
|
value: this.state.inputValue,
|
|
};
|
|
|
|
// If we can't accept additional tokens, override the events that would
|
|
// enable additional items to be inserted
|
|
if (this._atMaxTokens()) {
|
|
props.className = "noop-input"
|
|
props.onFocus = () => this._onInputFocused({noCompletions: true})
|
|
props.onPaste = () => 'noop-input'
|
|
props.onChange = () => 'noop'
|
|
props.value = ''
|
|
}
|
|
return (
|
|
<SizeToFitInput ref="input" spellCheck="false" {...props} />
|
|
)
|
|
}
|
|
|
|
_placeholderComponent() {
|
|
if (this.state.inputValue.length > 0 ||
|
|
this.props.placeholder === undefined ||
|
|
this.props.tokens.length > 0) {
|
|
return false;
|
|
}
|
|
return (<div className="placeholder">{this.props.placeholder}</div>)
|
|
}
|
|
|
|
_atMaxTokens() {
|
|
const {tokens, maxTokens} = this.props;
|
|
return !maxTokens ? false : (tokens.length >= maxTokens);
|
|
}
|
|
|
|
_renderPromptComponent() {
|
|
if (!this.props.menuPrompt) {
|
|
return false;
|
|
}
|
|
return (<div className="tokenizing-field-label">{`${this.props.menuPrompt}:`}</div>)
|
|
}
|
|
|
|
_fieldComponents() {
|
|
const {tokens, tokenKey, tokenIsValid, tokenRenderer, tokenClassNames, onTokenAction, onEdit} = this.props;
|
|
|
|
return tokens.map((item) => {
|
|
const key = tokenKey(item);
|
|
const valid = tokenIsValid ? tokenIsValid(item) : true;
|
|
|
|
const TokenRenderer = tokenRenderer
|
|
const onAction = (onTokenAction === false) ? null : (onTokenAction || this._showDefaultTokenMenu)
|
|
|
|
return (
|
|
<Token
|
|
className={tokenClassNames(item)}
|
|
item={item}
|
|
key={key}
|
|
valid={valid}
|
|
disabled={this.props.disabled}
|
|
selected={this.state.selectedKeys.includes(key)}
|
|
onDragStart={this._onDragToken}
|
|
onClick={this._onClickToken}
|
|
onEditMotion={this.props.onEditMotion}
|
|
onEdited={onEdit}
|
|
onAction={onAction}
|
|
>
|
|
<TokenRenderer token={item} />
|
|
</Token>
|
|
);
|
|
});
|
|
}
|
|
|
|
_fieldComponent() {
|
|
const fieldClasses = classNames({
|
|
"tokenizing-field-input": true,
|
|
"at-max-tokens": this._atMaxTokens(),
|
|
})
|
|
return (
|
|
<KeyCommandsRegion
|
|
key="field-component"
|
|
ref="field-drop-target"
|
|
localHandlers={{
|
|
"core:select-all": this._onSelectAll,
|
|
}}
|
|
className="tokenizing-field-wrap"
|
|
onClick={this._onClick}
|
|
onDrop={this._onDrop}
|
|
>
|
|
{this._renderPromptComponent()}
|
|
<div className={fieldClasses}>
|
|
{this._placeholderComponent()}
|
|
{this._fieldComponents()}
|
|
{this._inputComponent()}
|
|
</div>
|
|
</KeyCommandsRegion>
|
|
);
|
|
}
|
|
|
|
render() {
|
|
const classSet = {};
|
|
classSet[this.props.className] = true;
|
|
|
|
const classes = classNames(_.extend({}, classSet, (this.props.menuClassSet || {}), {
|
|
"tokenizing-field": true,
|
|
"disabled": this.props.disabled,
|
|
"focused": this.state.focus,
|
|
"empty": (this.state.inputValue || "").trim().length === 0,
|
|
}));
|
|
|
|
return (
|
|
<Menu
|
|
className={classes}
|
|
ref="completions"
|
|
items={this.state.completions}
|
|
itemKey={(item) => item.id}
|
|
itemContext={{inputValue: this.state.inputValue}}
|
|
itemContent={this.props.completionNode}
|
|
headerComponents={[this._fieldComponent()]}
|
|
onFocus={this._onInputFocused}
|
|
onBlur={this._onInputBlurred}
|
|
onSelect={this._addToken}
|
|
/>
|
|
);
|
|
}
|
|
}
|