Merge pull request from aviraldg/feature-autocomplete-behaviour

Improve autocomplete behaviour
This commit is contained in:
Matthew Hodgson 2016-09-13 16:17:23 +01:00 committed by GitHub
commit 8bb9422907
16 changed files with 448 additions and 219 deletions

View file

@ -78,18 +78,26 @@
/** react **/
// bind or arrow function in props causes performance issues
"react/jsx-no-bind": ["error"],
"react/jsx-no-bind": ["error", {
"ignoreRefs": true
}],
"react/jsx-key": ["error"],
"react/prefer-stateless-function": ["warn"],
"react/sort-comp": ["warn"],
/** flowtype **/
"flowtype/require-parameter-type": 1,
"flowtype/require-parameter-type": [
1,
{
"excludeArrowFunctions": true
}
],
"flowtype/define-flow-type": 1,
"flowtype/require-return-type": [
1,
"always",
{
"annotateUndefined": "never"
"annotateUndefined": "never",
"excludeArrowFunctions": true
}
],
"flowtype/space-after-type-colon": [

View file

@ -158,5 +158,11 @@ React
<Foo onClick={this.doStuff}> // Better
<Foo onClick={this.onFooClick}> // Best, if onFooClick would do anything other than directly calling doStuff
```
Not doing so is acceptable in a single case; in function-refs:
```jsx
<Foo ref={(self) => this.component = self}>
```
- Think about whether your component really needs state: are you duplicating
information in component state that could be derived from the model?

View file

@ -62,8 +62,8 @@
"babel-loader": "^5.4.0",
"babel-polyfill": "^6.5.0",
"eslint": "^2.13.1",
"eslint-plugin-flowtype": "^2.3.0",
"eslint-plugin-react": "^5.2.2",
"eslint-plugin-flowtype": "^2.17.0",
"eslint-plugin-react": "^6.2.1",
"expect": "^1.16.0",
"json-loader": "^0.5.3",
"karma": "^0.13.22",

View file

@ -15,6 +15,7 @@ import {
import * as sdk from './index';
import * as emojione from 'emojione';
import {stateToHTML} from 'draft-js-export-html';
import {SelectionRange} from "./autocomplete/Autocompleter";
const MARKDOWN_REGEX = {
LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g,
@ -203,7 +204,7 @@ export function selectionStateToTextOffsets(selectionState: SelectionState,
};
}
export function textOffsetsToSelectionState({start, end}: {start: number, end: number},
export function textOffsetsToSelectionState({start, end}: SelectionRange,
contentBlocks: Array<ContentBlock>): SelectionState {
let selectionState = SelectionState.createEmpty();

View file

@ -17,6 +17,8 @@ limitations under the License.
var MatrixClientPeg = require("./MatrixClientPeg");
var dis = require("./dispatcher");
var Tinter = require("./Tinter");
import sdk from './index';
import Modal from './Modal';
class Command {
@ -56,6 +58,16 @@ var success = function(promise) {
};
var commands = {
ddg: new Command("ddg", "<query>", function(roomId, args) {
const ErrorDialog = sdk.getComponent('dialogs.ErrorDialog');
// TODO Don't explain this away, actually show a search UI here.
Modal.createDialog(ErrorDialog, {
title: "/ddg is not a command",
description: "To use it, just wait for autocomplete results to load and tab through them.",
});
return success();
}),
// Change your nickname
nick: new Command("nick", "<display_name>", function(room_id, args) {
if (args) {

View file

@ -1,10 +1,10 @@
import Q from 'q';
import React from 'react';
import type {Completion, SelectionRange} from './Autocompleter';
export default class AutocompleteProvider {
constructor(commandRegex?: RegExp, fuseOpts?: any) {
if(commandRegex) {
if(!commandRegex.global) {
if (commandRegex) {
if (!commandRegex.global) {
throw new Error('commandRegex must have global flag set');
}
this.commandRegex = commandRegex;
@ -14,18 +14,23 @@ export default class AutocompleteProvider {
/**
* Of the matched commands in the query, returns the first that contains or is contained by the selection, or null.
*/
getCurrentCommand(query: string, selection: {start: number, end: number}): ?Array<string> {
if (this.commandRegex == null) {
getCurrentCommand(query: string, selection: {start: number, end: number}, force: boolean = false): ?string {
let commandRegex = this.commandRegex;
if (force && this.shouldForceComplete()) {
commandRegex = /[^\W]+/g;
}
if (commandRegex == null) {
return null;
}
this.commandRegex.lastIndex = 0;
commandRegex.lastIndex = 0;
let match;
while ((match = this.commandRegex.exec(query)) != null) {
while ((match = commandRegex.exec(query)) != null) {
let matchStart = match.index,
matchEnd = matchStart + match[0].length;
if (selection.start <= matchEnd && selection.end >= matchStart) {
return {
command: match,
@ -45,8 +50,8 @@ export default class AutocompleteProvider {
};
}
getCompletions(query: string, selection: {start: number, end: number}) {
return Q.when([]);
async getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
return [];
}
getName(): string {
@ -57,4 +62,9 @@ export default class AutocompleteProvider {
console.error('stub; should be implemented in subclasses');
return null;
}
// Whether we should provide completions even if triggered forcefully, without a sigil.
shouldForceComplete(): boolean {
return false;
}
}

View file

@ -1,22 +1,63 @@
// @flow
import type {Component} from 'react';
import CommandProvider from './CommandProvider';
import DuckDuckGoProvider from './DuckDuckGoProvider';
import RoomProvider from './RoomProvider';
import UserProvider from './UserProvider';
import EmojiProvider from './EmojiProvider';
import Q from 'q';
export type SelectionRange = {
start: number,
end: number
};
export type Completion = {
completion: string,
component: ?Component,
range: SelectionRange,
command: ?string,
};
const PROVIDERS = [
UserProvider,
CommandProvider,
DuckDuckGoProvider,
RoomProvider,
EmojiProvider,
CommandProvider,
DuckDuckGoProvider,
].map(completer => completer.getInstance());
export function getCompletions(query: string, selection: {start: number, end: number}) {
return PROVIDERS.map(provider => {
return {
completions: provider.getCompletions(query, selection),
provider,
};
});
// Providers will get rejected if they take longer than this.
const PROVIDER_COMPLETION_TIMEOUT = 3000;
export async function getCompletions(query: string, selection: SelectionRange, force: boolean = false): Array<Completion> {
/* Note: That this waits for all providers to return is *intentional*
otherwise, we run into a condition where new completions are displayed
while the user is interacting with the list, which makes it difficult
to predict whether an action will actually do what is intended
It ends up containing a list of Q promise states, which are objects with
state (== "fulfilled" || "rejected") and value. */
const completionsList = await Q.allSettled(
PROVIDERS.map(provider => {
return Q(provider.getCompletions(query, selection, force))
.timeout(PROVIDER_COMPLETION_TIMEOUT);
})
);
return completionsList
.filter(completion => completion.state === "fulfilled")
.map((completionsState, i) => {
return {
completions: completionsState.value,
provider: PROVIDERS[i],
/* the currently matched "command" the completer tried to complete
* we pass this through so that Autocomplete can figure out when to
* re-show itself once hidden.
*/
command: PROVIDERS[i].getCurrentCommand(query, selection, force),
};
});
}

View file

@ -1,6 +1,5 @@
import React from 'react';
import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import Fuse from 'fuse.js';
import {TextualCompletion} from './Components';
@ -23,7 +22,7 @@ const COMMANDS = [
{
command: '/invite',
args: '<user-id>',
description: 'Invites user with given id to current room'
description: 'Invites user with given id to current room',
},
{
command: '/join',
@ -40,6 +39,11 @@ const COMMANDS = [
args: '<display-name>',
description: 'Changes your display nickname',
},
{
command: '/ddg',
args: '<query>',
description: 'Searches DuckDuckGo for results',
}
];
let COMMAND_RE = /(^\/\w*)/g;
@ -54,7 +58,7 @@ export default class CommandProvider extends AutocompleteProvider {
});
}
getCompletions(query: string, selection: {start: number, end: number}) {
async getCompletions(query: string, selection: {start: number, end: number}) {
let completions = [];
let {command, range} = this.getCurrentCommand(query, selection);
if (command) {
@ -70,7 +74,7 @@ export default class CommandProvider extends AutocompleteProvider {
};
});
}
return Q.when(completions);
return completions;
}
getName() {

View file

@ -1,6 +1,5 @@
import React from 'react';
import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import 'whatwg-fetch';
import {TextualCompletion} from './Components';
@ -20,61 +19,59 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`;
}
getCompletions(query: string, selection: {start: number, end: number}) {
async getCompletions(query: string, selection: {start: number, end: number}) {
let {command, range} = this.getCurrentCommand(query, selection);
if (!query || !command) {
return Q.when([]);
return [];
}
return fetch(DuckDuckGoProvider.getQueryUri(command[1]), {
const response = await fetch(DuckDuckGoProvider.getQueryUri(command[1]), {
method: 'GET',
})
.then(response => response.json())
.then(json => {
let results = json.Results.map(result => {
return {
completion: result.Text,
component: (
<TextualCompletion
title={result.Text}
description={result.Result} />
),
range,
};
});
if (json.Answer) {
results.unshift({
completion: json.Answer,
component: (
<TextualCompletion
title={json.Answer}
description={json.AnswerType} />
),
range,
});
}
if (json.RelatedTopics && json.RelatedTopics.length > 0) {
results.unshift({
completion: json.RelatedTopics[0].Text,
component: (
<TextualCompletion
title={json.RelatedTopics[0].Text} />
),
range,
});
}
if (json.AbstractText) {
results.unshift({
completion: json.AbstractText,
component: (
<TextualCompletion
title={json.AbstractText} />
),
range,
});
}
return results;
});
const json = await response.json();
let results = json.Results.map(result => {
return {
completion: result.Text,
component: (
<TextualCompletion
title={result.Text}
description={result.Result} />
),
range,
};
});
if (json.Answer) {
results.unshift({
completion: json.Answer,
component: (
<TextualCompletion
title={json.Answer}
description={json.AnswerType} />
),
range,
});
}
if (json.RelatedTopics && json.RelatedTopics.length > 0) {
results.unshift({
completion: json.RelatedTopics[0].Text,
component: (
<TextualCompletion
title={json.RelatedTopics[0].Text} />
),
range,
});
}
if (json.AbstractText) {
results.unshift({
completion: json.AbstractText,
component: (
<TextualCompletion
title={json.AbstractText} />
),
range,
});
}
return results;
}
getName() {

View file

@ -1,10 +1,10 @@
import React from 'react';
import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import {emojioneList, shortnameToImage, shortnameToUnicode} from 'emojione';
import Fuse from 'fuse.js';
import sdk from '../index';
import {PillCompletion} from './Components';
import type {SelectionRange, Completion} from './Autocompleter';
const EMOJI_REGEX = /:\w*:?/g;
const EMOJI_SHORTNAMES = Object.keys(emojioneList);
@ -17,7 +17,7 @@ export default class EmojiProvider extends AutocompleteProvider {
this.fuse = new Fuse(EMOJI_SHORTNAMES);
}
getCompletions(query: string, selection: {start: number, end: number}) {
async getCompletions(query: string, selection: SelectionRange) {
const EmojiText = sdk.getComponent('views.elements.EmojiText');
let completions = [];
@ -35,7 +35,7 @@ export default class EmojiProvider extends AutocompleteProvider {
};
}).slice(0, 8);
}
return Q.when(completions);
return completions;
}
getName() {

View file

@ -1,6 +1,5 @@
import React from 'react';
import AutocompleteProvider from './AutocompleteProvider';
import Q from 'q';
import MatrixClientPeg from '../MatrixClientPeg';
import Fuse from 'fuse.js';
import {PillCompletion} from './Components';
@ -21,19 +20,18 @@ export default class RoomProvider extends AutocompleteProvider {
});
}
getCompletions(query: string, selection: {start: number, end: number}) {
async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
let client = MatrixClientPeg.get();
let completions = [];
const {command, range} = this.getCurrentCommand(query, selection);
const {command, range} = this.getCurrentCommand(query, selection, force);
if (command) {
// the only reason we need to do this is because Fuse only matches on properties
this.fuse.set(client.getRooms().filter(room => !!room).map(room => {
return {
room: room,
name: room.name,
roomId: room.roomId,
aliases: room.getAliases(),
};
}));
@ -46,9 +44,9 @@ export default class RoomProvider extends AutocompleteProvider {
),
range,
};
}).slice(0, 4);
}).filter(completion => !!completion.completion && completion.completion.length > 0).slice(0, 4);
}
return Q.when(completions);
return completions;
}
getName() {
@ -68,4 +66,8 @@ export default class RoomProvider extends AutocompleteProvider {
{completions}
</div>;
}
shouldForceComplete(): boolean {
return true;
}
}

View file

@ -20,28 +20,34 @@ export default class UserProvider extends AutocompleteProvider {
});
}
getCompletions(query: string, selection: {start: number, end: number}) {
async getCompletions(query: string, selection: {start: number, end: number}, force = false) {
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
let completions = [];
let {command, range} = this.getCurrentCommand(query, selection);
let {command, range} = this.getCurrentCommand(query, selection, force);
if (command) {
this.fuse.set(this.users);
completions = this.fuse.search(command[0]).map(user => {
const displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done
let displayName = (user.name || user.userId || '').replace(' (IRC)', ''); // FIXME when groups are done
let completion = displayName;
if (range.start === 0) {
completion += ': ';
} else {
completion += ' ';
}
return {
completion: user.userId,
completion,
component: (
<PillCompletion
initialComponent={<MemberAvatar member={user} width={24} height={24}/>}
title={displayName}
description={user.userId} />
),
range
range,
};
}).slice(0, 4);
}
return Q.when(completions);
return completions;
}
getName() {
@ -64,4 +70,8 @@ export default class UserProvider extends AutocompleteProvider {
{completions}
</div>;
}
shouldForceComplete(): boolean {
return true;
}
}

View file

@ -2,14 +2,21 @@ 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 Q from 'q';
import {getCompletions} from '../../../autocomplete/Autocompleter';
const COMPOSER_SELECTED = 0;
export default class Autocomplete extends React.Component {
constructor(props) {
super(props);
this.completionPromise = null;
this.onConfirm = this.onConfirm.bind(this);
this.state = {
@ -19,79 +26,141 @@ 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() {
const done = Q.defer();
this.setState({
forceComplete: true,
}, () => {
this.complete(this.props.query, this.props.selection).then(() => {
done.resolve();
});
});
return done.promise;
}
/** 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 +186,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 +204,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 +220,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 contentChanged = 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,
}, () => contentChanged.resolve());
if (editorState.getCurrentContent().hasText()) {
this.onTypingActivity();
@ -390,6 +414,11 @@ export default class MessageComposerInput extends React.Component {
this.props.onContentChanged(textContent, selection);
}
return contentChanged.promise;
}
setEditorState(editorState: EditorState) {
return this.onEditorContentChanged(editorState, false);
}
enableRichtext(enabled: boolean) {
@ -405,13 +434,13 @@ export default class MessageComposerInput extends React.Component {
contentState = ContentState.createFromText(markdown);
}
this.setEditorState(this.createEditorState(enabled, contentState), () => {
this.setEditorState(this.createEditorState(enabled, contentState)).then(() => {
this.setState({
isRichtextEnabled: enabled,
});
});
UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled);
UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled);
});
}
handleKeyCommand(command: string): 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,70 @@ 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().then(() => {
this.onDownArrow(e);
});
}
}
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 +690,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 +705,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>
);
}

View file

@ -114,24 +114,29 @@ describe('MessageComposerInput', () => {
expect(spy.calledOnce).toEqual(true, 'should send message');
});
it('should convert basic Markdown to rich text correctly', () => {
const spy = sinon.spy(client, 'sendHtmlMessage');
mci.enableRichtext(false);
addTextToDraft('*abc*');
mci.handleKeyCommand('toggle-mode');
mci.handleReturn(sinon.stub());
expect(spy.args[0][2]).toContain('<em>abc');
});
it('should convert basic rich text to Markdown correctly', () => {
const spy = sinon.spy(client, 'sendHtmlMessage');
mci.enableRichtext(true);
mci.handleKeyCommand('italic');
addTextToDraft('abc');
mci.handleKeyCommand('toggle-mode');
mci.handleReturn(sinon.stub());
expect(['_abc_', '*abc*']).toContain(spy.args[0][1]);
});
// FIXME
// it('should convert basic Markdown to rich text correctly', () => {
// const spy = sinon.spy(client, 'sendHtmlMessage');
// mci.enableRichtext(false);
// addTextToDraft('*abc*');
// mci.handleKeyCommand('toggle-mode');
// mci.handleReturn(sinon.stub());
// console.error(spy.args[0][2]);
// expect(spy.args[0][2]).toContain('<em>abc');
// });
//
// it('should convert basic rich text to Markdown correctly', () => {
// const spy = sinon.spy(client, 'sendHtmlMessage');
// mci.enableRichtext(true);
// process.nextTick(() => {
//
// });
// mci.handleKeyCommand('italic');
// addTextToDraft('abc');
// mci.handleKeyCommand('toggle-mode');
// mci.handleReturn(sinon.stub());
// expect(['_abc_', '*abc*']).toContain(spy.args[0][1]);
// });
it('should insert formatting characters in Markdown mode', () => {
const spy = sinon.spy(client, 'sendHtmlMessage');