Improve autocomplete behaviour

Fixes vector-im/vector-web#1761
This commit is contained in:
Aviral Dasgupta 2016-09-13 15:41:52 +05:30
parent ce40fa1a8f
commit b62622a814
15 changed files with 407 additions and 194 deletions

View file

@ -2,11 +2,17 @@ import React from 'react';
import ReactDOM from 'react-dom';
import classNames from 'classnames';
import flatMap from 'lodash/flatMap';
import isEqual from 'lodash/isEqual';
import sdk from '../../../index';
import type {Completion, SelectionRange} from '../../../autocomplete/Autocompleter';
import {getCompletions} from '../../../autocomplete/Autocompleter';
const COMPOSER_SELECTED = 0;
export default class Autocomplete extends React.Component {
completionPromise: Promise = null;
constructor(props) {
super(props);
@ -19,79 +25,137 @@ export default class Autocomplete extends React.Component {
// array of completions, so we can look up current selection by offset quickly
completionList: [],
// how far down the completion list we are
selectionOffset: 0,
// how far down the completion list we are (THIS IS 1-INDEXED!)
selectionOffset: COMPOSER_SELECTED,
// whether we should show completions if they're available
shouldShowCompletions: true,
hide: false,
forceComplete: false,
};
}
componentWillReceiveProps(props, state) {
async componentWillReceiveProps(props, state) {
if (props.query === this.props.query) {
return null;
}
return await this.complete(props.query, props.selection);
}
async complete(query, selection) {
let forceComplete = this.state.forceComplete;
const completionPromise = getCompletions(query, selection, forceComplete);
this.completionPromise = completionPromise;
const completions = await this.completionPromise;
// There's a newer completion request, so ignore results.
if (completionPromise !== this.completionPromise) {
return;
}
getCompletions(props.query, props.selection).forEach(completionResult => {
try {
completionResult.completions.then(completions => {
let i = this.state.completions.findIndex(
completion => completion.provider === completionResult.provider
);
const completionList = flatMap(completions, provider => provider.completions);
i = i === -1 ? this.state.completions.length : i;
let newCompletions = Object.assign([], this.state.completions);
completionResult.completions = completions;
newCompletions[i] = completionResult;
this.setState({
completions: newCompletions,
completionList: flatMap(newCompletions, provider => provider.completions),
});
}, err => {
console.error(err);
});
} catch (e) {
// An error in one provider shouldn't mess up the rest.
console.error(e);
// Reset selection when completion list becomes empty.
let selectionOffset = COMPOSER_SELECTED;
if (completionList.length > 0) {
/* If the currently selected completion is still in the completion list,
try to find it and jump to it. If not, select composer.
*/
const currentSelection = this.state.selectionOffset === 0 ? null :
this.state.completionList[this.state.selectionOffset - 1].completion;
selectionOffset = completionList.findIndex(
completion => completion.completion === currentSelection);
if (selectionOffset === -1) {
selectionOffset = COMPOSER_SELECTED;
} else {
selectionOffset++; // selectionOffset is 1-indexed!
}
} else {
// If no completions were returned, we should turn off force completion.
forceComplete = false;
}
let hide = this.state.hide;
// These are lists of booleans that indicate whether whether the corresponding provider had a matching pattern
const oldMatches = this.state.completions.map(completion => !!completion.command.command),
newMatches = completions.map(completion => !!completion.command.command);
// So, essentially, we re-show autocomplete if any provider finds a new pattern or stops finding an old one
if (!isEqual(oldMatches, newMatches)) {
hide = false;
}
this.setState({
completions,
completionList,
selectionOffset,
hide,
forceComplete,
});
}
countCompletions(): number {
return this.state.completions.map(completionResult => {
return completionResult.completions.length;
}).reduce((l, r) => l + r);
return this.state.completionList.length;
}
// called from MessageComposerInput
onUpArrow(): boolean {
let completionCount = this.countCompletions(),
selectionOffset = (completionCount + this.state.selectionOffset - 1) % completionCount;
onUpArrow(): ?Completion {
const completionCount = this.countCompletions();
// completionCount + 1, since 0 means composer is selected
const selectionOffset = (completionCount + 1 + this.state.selectionOffset - 1)
% (completionCount + 1);
if (!completionCount) {
return false;
return null;
}
this.setSelection(selectionOffset);
return true;
return selectionOffset === COMPOSER_SELECTED ? null : this.state.completionList[selectionOffset - 1];
}
// called from MessageComposerInput
onDownArrow(): boolean {
let completionCount = this.countCompletions(),
selectionOffset = (this.state.selectionOffset + 1) % completionCount;
onDownArrow(): ?Completion {
const completionCount = this.countCompletions();
// completionCount + 1, since 0 means composer is selected
const selectionOffset = (this.state.selectionOffset + 1) % (completionCount + 1);
if (!completionCount) {
return false;
return null;
}
this.setSelection(selectionOffset);
return true;
return selectionOffset === COMPOSER_SELECTED ? null : this.state.completionList[selectionOffset - 1];
}
onEscape(e): boolean {
const completionCount = this.countCompletions();
if (completionCount === 0) {
// autocomplete is already empty, so don't preventDefault
return;
}
e.preventDefault();
// selectionOffset = 0, so we don't end up completing when autocomplete is hidden
this.setState({hide: true, selectionOffset: 0});
}
forceComplete() {
this.setState({
forceComplete: true,
}, () => {
this.complete(this.props.query, this.props.selection);
});
}
/** called from MessageComposerInput
* @returns {boolean} whether confirmation was handled
*/
onConfirm(): boolean {
if (this.countCompletions() === 0) {
if (this.countCompletions() === 0 || this.state.selectionOffset === COMPOSER_SELECTED) {
return false;
}
let selectedCompletion = this.state.completionList[this.state.selectionOffset];
let selectedCompletion = this.state.completionList[this.state.selectionOffset - 1];
this.props.onConfirm(selectedCompletion.range, selectedCompletion.completion);
return true;
@ -117,7 +181,7 @@ export default class Autocomplete extends React.Component {
render() {
const EmojiText = sdk.getComponent('views.elements.EmojiText');
let position = 0;
let position = 1;
let renderedCompletions = this.state.completions.map((completionResult, i) => {
let completions = completionResult.completions.map((completion, i) => {
@ -135,7 +199,7 @@ export default class Autocomplete extends React.Component {
return React.cloneElement(completion.component, {
key: i,
ref: `completion${i}`,
ref: `completion${position - 1}`,
className,
onMouseOver,
onClick,
@ -151,7 +215,7 @@ export default class Autocomplete extends React.Component {
) : null;
}).filter(completion => !!completion);
return renderedCompletions.length > 0 ? (
return !this.state.hide && renderedCompletions.length > 0 ? (
<div className="mx_Autocomplete" ref={(e) => this.container = e}>
{renderedCompletions}
</div>

View file

@ -166,7 +166,7 @@ export default class MessageComposer extends React.Component {
_onAutocompleteConfirm(range, completion) {
if (this.messageComposerInput) {
this.messageComposerInput.onConfirmAutocompletion(range, completion);
this.messageComposerInput.setDisplayedCompletion(range, completion);
}
}
@ -313,7 +313,6 @@ export default class MessageComposer extends React.Component {
return (
<div className="mx_MessageComposer mx_fadable" style={{ opacity: this.props.opacity }}>
{autoComplete}
<div className="mx_MessageComposer_wrapper">
<div className="mx_MessageComposer_row">
{controls}

View file

@ -34,6 +34,7 @@ import {Editor, EditorState, RichUtils, CompositeDecorator,
import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown';
import classNames from 'classnames';
import escape from 'lodash/escape';
import Q from 'q';
import MatrixClientPeg from '../../../MatrixClientPeg';
import type {MatrixClient} from 'matrix-js-sdk/lib/matrix';
@ -46,6 +47,8 @@ import KeyCode from '../../../KeyCode';
import UserSettingsStore from '../../../UserSettingsStore';
import * as RichText from '../../../RichText';
import Autocomplete from './Autocomplete';
import {Completion} from "../../../autocomplete/Autocompleter";
const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000;
@ -88,34 +91,52 @@ export default class MessageComposerInput extends React.Component {
return getDefaultKeyBinding(e);
}
static getBlockStyle(block: ContentBlock): ?string {
if (block.getType() === 'strikethrough') {
return 'mx_Markdown_STRIKETHROUGH';
}
return null;
}
client: MatrixClient;
autocomplete: Autocomplete;
constructor(props, context) {
super(props, context);
this.onAction = this.onAction.bind(this);
this.handleReturn = this.handleReturn.bind(this);
this.handleKeyCommand = this.handleKeyCommand.bind(this);
this.onEditorContentChanged = this.onEditorContentChanged.bind(this);
this.setEditorState = this.setEditorState.bind(this);
this.onUpArrow = this.onUpArrow.bind(this);
this.onDownArrow = this.onDownArrow.bind(this);
this.onTab = this.onTab.bind(this);
this.onConfirmAutocompletion = this.onConfirmAutocompletion.bind(this);
this.onEscape = this.onEscape.bind(this);
this.setDisplayedCompletion = this.setDisplayedCompletion.bind(this);
this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this);
const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true);
this.state = {
// whether we're in rich text or markdown mode
isRichtextEnabled,
// the currently displayed editor state (note: this is always what is modified on input)
editorState: null,
// the original editor state, before we started tabbing through completions
originalEditorState: null,
};
// bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled
/* eslint react/no-direct-mutation-state:0 */
this.state.editorState = this.createEditorState();
this.client = MatrixClientPeg.get();
}
/**
/*
* "Does the right thing" to create an EditorState, based on:
* - whether we've got rich text mode enabled
* - contentState was passed in
@ -234,10 +255,6 @@ export default class MessageComposerInput extends React.Component {
this.refs.editor,
this.props.room.roomId
);
// this is disabled for now, since https://github.com/matrix-org/matrix-react-sdk/pull/296 will land soon
// if (this.props.tabComplete) {
// this.props.tabComplete.setEditor(this.refs.editor);
// }
}
componentWillUnmount() {
@ -273,7 +290,7 @@ export default class MessageComposerInput extends React.Component {
);
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter());
this.setEditorState(editorState);
this.onEditorContentChanged(editorState);
editor.focus();
}
break;
@ -295,10 +312,11 @@ export default class MessageComposerInput extends React.Component {
startSelection,
blockMap);
startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey());
if (this.state.isRichtextEnabled)
if (this.state.isRichtextEnabled) {
contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote');
}
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
this.setEditorState(editorState);
this.onEditorContentChanged(editorState);
editor.focus();
}
}
@ -372,10 +390,16 @@ export default class MessageComposerInput extends React.Component {
}
}
setEditorState(editorState: EditorState, cb = () => null) {
// Called by Draft to change editor contents, and by setEditorState
onEditorContentChanged(editorState: EditorState, didRespondToUserInput: boolean = true) {
editorState = RichText.attachImmutableEntitiesToEmoji(editorState);
this.setState({editorState}, cb);
const setPromise = Q.defer();
/* If a modification was made, set originalEditorState to null, since newState is now our original */
this.setState({
editorState,
originalEditorState: didRespondToUserInput ? null : this.state.originalEditorState,
}, () => setPromise.resolve());
if (editorState.getCurrentContent().hasText()) {
this.onTypingActivity();
@ -390,6 +414,11 @@ export default class MessageComposerInput extends React.Component {
this.props.onContentChanged(textContent, selection);
}
return setPromise;
}
setEditorState(editorState: EditorState) {
this.onEditorContentChanged(editorState, false);
}
enableRichtext(enabled: boolean) {
@ -470,7 +499,7 @@ export default class MessageComposerInput extends React.Component {
handleReturn(ev) {
if (ev.shiftKey) {
this.setEditorState(RichUtils.insertSoftNewline(this.state.editorState));
this.onEditorContentChanged(RichUtils.insertSoftNewline(this.state.editorState));
return true;
}
@ -547,41 +576,68 @@ export default class MessageComposerInput extends React.Component {
return true;
}
onUpArrow(e) {
if (this.props.onUpArrow && this.props.onUpArrow()) {
async onUpArrow(e) {
const completion = this.autocomplete.onUpArrow();
if (completion != null) {
e.preventDefault();
}
return await this.setDisplayedCompletion(completion);
}
async onDownArrow(e) {
const completion = this.autocomplete.onDownArrow();
e.preventDefault();
return await this.setDisplayedCompletion(completion);
}
// tab and shift-tab are mapped to down and up arrow respectively
async onTab(e) {
e.preventDefault(); // we *never* want tab's default to happen, but we do want up/down sometimes
const didTab = await (e.shiftKey ? this.onUpArrow : this.onDownArrow)(e);
if (!didTab && this.autocomplete) {
this.autocomplete.forceComplete();
}
}
onDownArrow(e) {
if (this.props.onDownArrow && this.props.onDownArrow()) {
e.preventDefault();
onEscape(e) {
e.preventDefault();
if (this.autocomplete) {
this.autocomplete.onEscape(e);
}
this.setDisplayedCompletion(null); // restore originalEditorState
}
onTab(e) {
if (this.props.tryComplete) {
if (this.props.tryComplete()) {
e.preventDefault();
/* If passed null, restores the original editor content from state.originalEditorState.
* If passed a non-null displayedCompletion, modifies state.originalEditorState to compute new state.editorState.
*/
async setDisplayedCompletion(displayedCompletion: ?Completion): boolean {
const activeEditorState = this.state.originalEditorState || this.state.editorState;
if (displayedCompletion == null) {
if (this.state.originalEditorState) {
this.setEditorState(this.state.originalEditorState);
}
return false;
}
}
onConfirmAutocompletion(range, content: string) {
const {range = {}, completion = ''} = displayedCompletion;
let contentState = Modifier.replaceText(
this.state.editorState.getCurrentContent(),
RichText.textOffsetsToSelectionState(range, this.state.editorState.getCurrentContent().getBlocksAsArray()),
content
activeEditorState.getCurrentContent(),
RichText.textOffsetsToSelectionState(range, activeEditorState.getCurrentContent().getBlocksAsArray()),
completion
);
let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
let editorState = EditorState.push(activeEditorState, contentState, 'insert-characters');
editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter());
const originalEditorState = activeEditorState;
this.setEditorState(editorState);
await this.setEditorState(editorState);
this.setState({originalEditorState});
// for some reason, doing this right away does not update the editor :(
setTimeout(() => this.refs.editor.focus(), 50);
return true;
}
onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", e) {
@ -632,22 +688,14 @@ export default class MessageComposerInput extends React.Component {
this.handleKeyCommand('toggle-mode');
}
getBlockStyle(block: ContentBlock): ?string {
if (block.getType() === 'strikethrough') {
return 'mx_Markdown_STRIKETHROUGH';
}
return null;
}
render() {
const {editorState} = this.state;
const activeEditorState = this.state.originalEditorState || this.state.editorState;
// From https://github.com/facebook/draft-js/blob/master/examples/rich/rich.html#L92
// If the user changes block type before entering any text, we can
// either style the placeholder or hide it.
let hidePlaceholder = false;
const contentState = editorState.getCurrentContent();
const contentState = activeEditorState.getCurrentContent();
if (!contentState.hasText()) {
if (contentState.getBlockMap().first().getType() !== 'unstyled') {
hidePlaceholder = true;
@ -655,28 +703,43 @@ export default class MessageComposerInput extends React.Component {
}
const className = classNames('mx_MessageComposer_input', {
mx_MessageComposer_input_empty: hidePlaceholder,
mx_MessageComposer_input_empty: hidePlaceholder,
});
const content = activeEditorState.getCurrentContent();
const contentText = content.getPlainText();
const selection = RichText.selectionStateToTextOffsets(activeEditorState.getSelection(),
activeEditorState.getCurrentContent().getBlocksAsArray());
return (
<div className={className}>
<img className="mx_MessageComposer_input_markdownIndicator"
onMouseDown={this.onMarkdownToggleClicked}
title={`Markdown is ${this.state.isRichtextEnabled ? 'disabled' : 'enabled'}`}
src={`img/button-md-${!this.state.isRichtextEnabled}.png`} />
<Editor ref="editor"
placeholder="Type a message…"
editorState={this.state.editorState}
onChange={this.setEditorState}
blockStyleFn={this.getBlockStyle}
keyBindingFn={MessageComposerInput.getKeyBinding}
handleKeyCommand={this.handleKeyCommand}
handleReturn={this.handleReturn}
stripPastedStyles={!this.state.isRichtextEnabled}
onTab={this.onTab}
onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow}
spellCheck={true} />
<div className="mx_MessageComposer_input_wrapper">
<div className="mx_MessageComposer_autocomplete_wrapper">
<Autocomplete
ref={(e) => this.autocomplete = e}
onConfirm={this.setDisplayedCompletion}
query={contentText}
selection={selection} />
</div>
<div className={className}>
<img className="mx_MessageComposer_input_markdownIndicator"
onMouseDown={this.onMarkdownToggleClicked}
title={`Markdown is ${this.state.isRichtextEnabled ? 'disabled' : 'enabled'}`}
src={`img/button-md-${!this.state.isRichtextEnabled}.png`} />
<Editor ref="editor"
placeholder="Type a message…"
editorState={this.state.editorState}
onChange={this.onEditorContentChanged}
blockStyleFn={MessageComposerInput.getBlockStyle}
keyBindingFn={MessageComposerInput.getKeyBinding}
handleKeyCommand={this.handleKeyCommand}
handleReturn={this.handleReturn}
stripPastedStyles={!this.state.isRichtextEnabled}
onTab={this.onTab}
onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow}
onEscape={this.onEscape}
spellCheck={true} />
</div>
</div>
);
}