autocomplete polishing

* suppress autocomplete when navigating through history
* only search for slashcommands if in the first block of the editor
* handle suffix returns from providers correctly
* fix SelectionRange typing in the providers
* fix bugs when pressing ctrl-a, typing and then tab to complete a replacement by collapsing selection to anchor when inserting a completion in the editor
* fix https://github.com/vector-im/riot-web/issues/4762
pull/21833/head
Matthew Hodgson 2018-05-13 03:04:40 +01:00
parent 877a6195ae
commit c967ecc4e5
7 changed files with 31 additions and 28 deletions

View File

@ -20,13 +20,19 @@ import React from 'react';
import type {Completion, SelectionRange} from './Autocompleter'; import type {Completion, SelectionRange} from './Autocompleter';
export default class AutocompleteProvider { export default class AutocompleteProvider {
constructor(commandRegex?: RegExp) { constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) {
if (commandRegex) { if (commandRegex) {
if (!commandRegex.global) { if (!commandRegex.global) {
throw new Error('commandRegex must have global flag set'); throw new Error('commandRegex must have global flag set');
} }
this.commandRegex = commandRegex; this.commandRegex = commandRegex;
} }
if (forcedCommandRegex) {
if (!forcedCommandRegex.global) {
throw new Error('forcedCommandRegex must have global flag set');
}
this.forcedCommandRegex = forcedCommandRegex;
}
} }
destroy() { destroy() {
@ -36,11 +42,11 @@ export default class AutocompleteProvider {
/** /**
* Of the matched commands in the query, returns the first that contains or is contained by the selection, or null. * 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}, force: boolean = false): ?string { getCurrentCommand(query: string, selection: SelectionRange, force: boolean = false): ?string {
let commandRegex = this.commandRegex; let commandRegex = this.commandRegex;
if (force && this.shouldForceComplete()) { if (force && this.shouldForceComplete()) {
commandRegex = /\S+/g; commandRegex = this.forcedCommandRegex || /\S+/g;
} }
if (commandRegex == null) { if (commandRegex == null) {

View File

@ -27,6 +27,7 @@ import NotifProvider from './NotifProvider';
import Promise from 'bluebird'; import Promise from 'bluebird';
export type SelectionRange = { export type SelectionRange = {
beginning: boolean,
start: number, start: number,
end: number end: number
}; };
@ -77,12 +78,12 @@ export default class Autocompleter {
// Array of inspections of promises that might timeout. Instead of allowing a // Array of inspections of promises that might timeout. Instead of allowing a
// single timeout to reject the Promise.all, reflect each one and once they've all // single timeout to reject the Promise.all, reflect each one and once they've all
// settled, filter for the fulfilled ones // settled, filter for the fulfilled ones
this.providers.map((provider) => { this.providers.map(provider =>
return provider provider
.getCompletions(query, selection, force) .getCompletions(query, selection, force)
.timeout(PROVIDER_COMPLETION_TIMEOUT) .timeout(PROVIDER_COMPLETION_TIMEOUT)
.reflect(); .reflect()
}), ),
); );
return completionsList.filter( return completionsList.filter(

View File

@ -21,6 +21,7 @@ import { _t, _td } from '../languageHandler';
import AutocompleteProvider from './AutocompleteProvider'; import AutocompleteProvider from './AutocompleteProvider';
import FuzzyMatcher from './FuzzyMatcher'; import FuzzyMatcher from './FuzzyMatcher';
import {TextualCompletion} from './Components'; import {TextualCompletion} from './Components';
import type {SelectionRange} from './Autocompleter';
// TODO merge this with the factory mechanics of SlashCommands? // TODO merge this with the factory mechanics of SlashCommands?
// Warning: Since the description string will be translated in _t(result.description), all these strings below must be in i18n/strings/en_EN.json file // Warning: Since the description string will be translated in _t(result.description), all these strings below must be in i18n/strings/en_EN.json file
@ -123,8 +124,9 @@ export default class CommandProvider extends AutocompleteProvider {
}); });
} }
async getCompletions(query: string, selection: {start: number, end: number}) { async getCompletions(query: string, selection: SelectionRange) {
let completions = []; let completions = [];
if (!selection.beginning) return completions;
const {command, range} = this.getCurrentCommand(query, selection); const {command, range} = this.getCurrentCommand(query, selection);
if (command) { if (command) {
completions = this.matcher.match(command[0]).map((result) => { completions = this.matcher.match(command[0]).map((result) => {

View File

@ -22,6 +22,7 @@ import AutocompleteProvider from './AutocompleteProvider';
import 'whatwg-fetch'; import 'whatwg-fetch';
import {TextualCompletion} from './Components'; import {TextualCompletion} from './Components';
import type {SelectionRange} from './Autocompleter';
const DDG_REGEX = /\/ddg\s+(.+)$/g; const DDG_REGEX = /\/ddg\s+(.+)$/g;
const REFERRER = 'vector'; const REFERRER = 'vector';
@ -36,7 +37,7 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`; + `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERRER)}`;
} }
async getCompletions(query: string, selection: {start: number, end: number}) { async getCompletions(query: string, selection: SelectionRange) {
const {command, range} = this.getCurrentCommand(query, selection); const {command, range} = this.getCurrentCommand(query, selection);
if (!query || !command) { if (!query || !command) {
return []; return [];

View File

@ -20,6 +20,7 @@ import { _t } from '../languageHandler';
import MatrixClientPeg from '../MatrixClientPeg'; import MatrixClientPeg from '../MatrixClientPeg';
import {PillCompletion} from './Components'; import {PillCompletion} from './Components';
import sdk from '../index'; import sdk from '../index';
import type {SelectionRange} from './Autocompleter';
const AT_ROOM_REGEX = /@\S*/g; const AT_ROOM_REGEX = /@\S*/g;
@ -29,7 +30,7 @@ export default class NotifProvider extends AutocompleteProvider {
this.room = room; this.room = room;
} }
async getCompletions(query: string, selection: {start: number, end: number}, force = false) { async getCompletions(query: string, selection: SelectionRange, force = false) {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();

View File

@ -26,6 +26,7 @@ import {getDisplayAliasForRoom} from '../Rooms';
import sdk from '../index'; import sdk from '../index';
import _sortBy from 'lodash/sortBy'; import _sortBy from 'lodash/sortBy';
import {makeRoomPermalink} from "../matrix-to"; import {makeRoomPermalink} from "../matrix-to";
import type {SelectionRange} from './Autocompleter';
const ROOM_REGEX = /(?=#)(\S*)/g; const ROOM_REGEX = /(?=#)(\S*)/g;
@ -46,15 +47,9 @@ export default class RoomProvider extends AutocompleteProvider {
}); });
} }
async getCompletions(query: string, selection: {start: number, end: number}, force = false) { async getCompletions(query: string, selection: SelectionRange, force = false) {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
// Disable autocompletions when composing commands because of various issues
// (see https://github.com/vector-im/riot-web/issues/4762)
if (/^(\/join|\/leave)/.test(query)) {
return [];
}
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
let completions = []; let completions = [];
const {command, range} = this.getCurrentCommand(query, selection, force); const {command, range} = this.getCurrentCommand(query, selection, force);

View File

@ -28,18 +28,21 @@ import _sortBy from 'lodash/sortBy';
import MatrixClientPeg from '../MatrixClientPeg'; import MatrixClientPeg from '../MatrixClientPeg';
import type {Room, RoomMember} from 'matrix-js-sdk'; import type {Room, RoomMember} from 'matrix-js-sdk';
import type {SelectionRange} from './Autocompleter';
import {makeUserPermalink} from "../matrix-to"; import {makeUserPermalink} from "../matrix-to";
const USER_REGEX = /@\S*/g; const USER_REGEX = /@\S*/g;
// used when you hit 'tab' - we allow some separator chars at the beginning
// to allow you to tab-complete /mat into /(matthew)
const FORCED_USER_REGEX = /[^/,:; \t\n]\S*/g;
export default class UserProvider extends AutocompleteProvider { export default class UserProvider extends AutocompleteProvider {
users: Array<RoomMember> = null; users: Array<RoomMember> = null;
room: Room = null; room: Room = null;
constructor(room) { constructor(room) {
super(USER_REGEX, { super(USER_REGEX, FORCED_USER_REGEX);
keys: ['name'],
});
this.room = room; this.room = room;
this.matcher = new FuzzyMatcher([], { this.matcher = new FuzzyMatcher([], {
keys: ['name', 'userId'], keys: ['name', 'userId'],
@ -87,15 +90,9 @@ export default class UserProvider extends AutocompleteProvider {
this.users = null; this.users = null;
} }
async getCompletions(query: string, selection: {start: number, end: number}, force = false) { async getCompletions(query: string, selection: SelectionRange, force = false) {
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar'); const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
// Disable autocompletions when composing commands because of various issues
// (see https://github.com/vector-im/riot-web/issues/4762)
if (/^(\/ban|\/unban|\/op|\/deop|\/invite|\/kick|\/verify)/.test(query)) {
return [];
}
// lazy-load user list into matcher // lazy-load user list into matcher
if (this.users === null) this._makeUsers(); if (this.users === null) this._makeUsers();
@ -114,7 +111,7 @@ export default class UserProvider extends AutocompleteProvider {
// relies on the length of the entity === length of the text in the decoration. // relies on the length of the entity === length of the text in the decoration.
completion: user.rawDisplayName.replace(' (IRC)', ''), completion: user.rawDisplayName.replace(' (IRC)', ''),
completionId: user.userId, completionId: user.userId,
suffix: range.start === 0 ? ': ' : ' ', suffix: (selection.beginning && range.start === 0) ? ': ' : ' ',
href: makeUserPermalink(user.userId), href: makeUserPermalink(user.userId),
component: ( component: (
<PillCompletion <PillCompletion