Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Rework composer autocomplete to be smarter and not trap tab #5659

Merged
merged 12 commits into from
Aug 13, 2021
Merged
14 changes: 6 additions & 8 deletions src/KeyBindingsDefaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,31 +161,29 @@ const messageComposerBindings = (): KeyBinding<MessageComposerAction>[] => {
const autocompleteBindings = (): KeyBinding<AutocompleteAction>[] => {
return [
{
action: AutocompleteAction.CompleteOrNextSelection,
action: AutocompleteAction.ForceComplete,
keyCombo: {
key: Key.TAB,
},
},
{
action: AutocompleteAction.CompleteOrNextSelection,
action: AutocompleteAction.ForceComplete,
keyCombo: {
key: Key.TAB,
ctrlKey: true,
},
},
{
action: AutocompleteAction.CompleteOrPrevSelection,
action: AutocompleteAction.Complete,
keyCombo: {
key: Key.TAB,
shiftKey: true,
key: Key.ENTER,
},
},
{
action: AutocompleteAction.CompleteOrPrevSelection,
action: AutocompleteAction.Complete,
keyCombo: {
key: Key.TAB,
key: Key.ENTER,
ctrlKey: true,
shiftKey: true,
},
},
{
Expand Down
12 changes: 5 additions & 7 deletions src/KeyBindingsManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,13 +52,11 @@ export enum MessageComposerAction {

/** Actions for text editing autocompletion */
export enum AutocompleteAction {
/**
* Select previous selection or, if the autocompletion window is not shown, open the window and select the first
* selection.
*/
CompleteOrPrevSelection = 'ApplySelection',
/** Select next selection or, if the autocompletion window is not shown, open it and select the first selection */
CompleteOrNextSelection = 'CompleteOrNextSelection',
/** Accepts chosen autocomplete selection */
Complete = 'Complete',
/** Accepts chosen autocomplete selection or,
* if the autocompletion window is not shown, open the window and select the first selection */
ForceComplete = 'ForceComplete',
/** Move to the previous autocomplete selection */
PrevSelection = 'PrevSelection',
/** Move to the next autocomplete selection */
Expand Down
23 changes: 8 additions & 15 deletions src/autocomplete/AutocompleteProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,11 @@ export interface ICommand {
};
}

export default class AutocompleteProvider {
export default abstract class AutocompleteProvider {
commandRegex: RegExp;
forcedCommandRegex: RegExp;

constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) {
protected constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) {
if (commandRegex) {
if (!commandRegex.global) {
throw new Error('commandRegex must have global flag set');
Expand Down Expand Up @@ -93,23 +93,16 @@ export default class AutocompleteProvider {
};
}

async getCompletions(
abstract getCompletions(
query: string,
selection: ISelectionRange,
force = false,
limit = -1,
): Promise<ICompletion[]> {
return [];
}
force: boolean,
limit: number,
): Promise<ICompletion[]>;

getName(): string {
return 'Default Provider';
}
abstract getName(): string;

renderCompletions(completions: React.ReactNode[]): React.ReactNode | null {
console.error('stub; should be implemented in subclasses');
return null;
}
abstract renderCompletions(completions: React.ReactNode[]): React.ReactNode | null;

// Whether we should provide completions even if triggered forcefully, without a sigil.
shouldForceComplete(): boolean {
Expand Down
2 changes: 1 addition & 1 deletion src/autocomplete/CommandProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ export default class CommandProvider extends AutocompleteProvider {
return (
<div
className="mx_Autocomplete_Completion_container_block"
role="listbox"
role="presentation"
aria-label={_t("Command Autocomplete")}
>
{ completions }
Expand Down
2 changes: 1 addition & 1 deletion src/autocomplete/CommunityProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export default class CommunityProvider extends AutocompleteProvider {
return (
<div
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
role="listbox"
role="presentation"
aria-label={_t("Community Autocomplete")}
>
{ completions }
Expand Down
2 changes: 1 addition & 1 deletion src/autocomplete/DuckDuckGoProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export default class DuckDuckGoProvider extends AutocompleteProvider {
return (
<div
className="mx_Autocomplete_Completion_container_block"
role="listbox"
role="presentation"
aria-label={_t("DuckDuckGo Results")}
>
{ completions }
Expand Down
2 changes: 1 addition & 1 deletion src/autocomplete/EmojiProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ export default class EmojiProvider extends AutocompleteProvider {
return (
<div
className="mx_Autocomplete_Completion_container_pill"
role="listbox"
role="presentation"
aria-label={_t("Emoji Autocomplete")}
>
{ completions }
Expand Down
2 changes: 1 addition & 1 deletion src/autocomplete/NotifProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export default class NotifProvider extends AutocompleteProvider {
return (
<div
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
role="listbox"
role="presentation"
aria-label={_t("Notification Autocomplete")}
>
{ completions }
Expand Down
2 changes: 1 addition & 1 deletion src/autocomplete/RoomProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ export default class RoomProvider extends AutocompleteProvider {
return (
<div
className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
role="listbox"
role="presentation"
aria-label={_t("Room Autocomplete")}
>
{ completions }
Expand Down
2 changes: 1 addition & 1 deletion src/autocomplete/UserProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export default class UserProvider extends AutocompleteProvider {
return (
<div
className="mx_Autocomplete_Completion_container_pill"
role="listbox"
role="presentation"
aria-label={_t("User Autocomplete")}
>
{ completions }
Expand Down
18 changes: 9 additions & 9 deletions src/components/structures/LoggedInView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -537,24 +537,24 @@ class LoggedInView extends React.Component<IProps, IState> {
}

const isModifier = ev.key === Key.ALT || ev.key === Key.CONTROL || ev.key === Key.META || ev.key === Key.SHIFT;
if (!isModifier && !ev.altKey && !ev.ctrlKey && !ev.metaKey) {
if (!isModifier && !ev.ctrlKey && !ev.metaKey) {
// The above condition is crafted to _allow_ characters with Shift
// already pressed (but not the Shift key down itself).

const isClickShortcut = ev.target !== document.body &&
(ev.key === Key.SPACE || ev.key === Key.ENTER);

// Do not capture the context menu key to improve keyboard accessibility
if (ev.key === Key.CONTEXT_MENU) {
return;
}
// We explicitly allow alt to be held due to it being a common accent modifier.
// XXX: Forwarding Dead keys in this way does not work as intended but better to at least
// move focus to the composer so the user can re-type the dead key correctly.
const isPrintable = ev.key.length === 1 || ev.key === "Dead";

if (!isClickShortcut && ev.key !== Key.TAB && !canElementReceiveInput(ev.target)) {
// If the user is entering a printable character outside of an input field
// redirect it to the composer for them.
if (!isClickShortcut && isPrintable && !canElementReceiveInput(ev.target)) {
// synchronous dispatch so we focus before key generates input
dis.fire(Action.FocusComposer, true);
ev.stopPropagation();
// we should *not* preventDefault() here as
// that would prevent typing in the now-focussed composer
// we should *not* preventDefault() here as that would prevent typing in the now-focused composer
}
}
};
Expand Down
43 changes: 26 additions & 17 deletions src/components/views/rooms/Autocomplete.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@ import SettingsStore from "../../../settings/SettingsStore";
import Autocompleter from '../../../autocomplete/Autocompleter';
import {replaceableComponent} from "../../../utils/replaceableComponent";

const COMPOSER_SELECTED = 0;
const MAX_PROVIDER_MATCHES = 20;

export const generateCompletionDomId = (number) => `mx_Autocomplete_Completion_${number}`;
Expand All @@ -34,9 +33,9 @@ interface IProps {
// the query string for which to show autocomplete suggestions
query: string;
// method invoked with range and text content when completion is confirmed
onConfirm: (ICompletion) => void;
onConfirm: (completion: ICompletion) => void;
// method invoked when selected (if any) completion changes
onSelectionChange?: (ICompletion, number) => void;
onSelectionChange?: (partIndex: number) => void;
selection: ISelectionRange;
// The room in which we're autocompleting
room: Room;
Expand Down Expand Up @@ -71,7 +70,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
completionList: [],

// how far down the completion list we are (THIS IS 1-INDEXED!)
selectionOffset: COMPOSER_SELECTED,
selectionOffset: 1,

// whether we should show completions if they're available
shouldShowCompletions: true,
Expand Down Expand Up @@ -115,7 +114,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
completions: [],
completionList: [],
// Reset selected completion
selectionOffset: COMPOSER_SELECTED,
selectionOffset: 1,
// Hide the autocomplete box
hide: true,
});
Expand Down Expand Up @@ -151,26 +150,31 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
const completionList = flatMap(completions, (provider) => provider.completions);

// Reset selection when completion list becomes empty.
let selectionOffset = COMPOSER_SELECTED;
let selectionOffset = 1;
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 :
const currentSelection = this.state.selectionOffset <= 1 ? null :
this.state.completionList[this.state.selectionOffset - 1].completion;
selectionOffset = completionList.findIndex(
(completion) => completion.completion === currentSelection);
if (selectionOffset === -1) {
selectionOffset = COMPOSER_SELECTED;
selectionOffset = 1;
} else {
selectionOffset++; // selectionOffset is 1-indexed!
}
}

let hide = this.state.hide;
let hide = true;
// If `completion.command.command` is truthy, then a provider has matched with the query
const anyMatches = completions.some((completion) => !!completion.command.command);
hide = !anyMatches;
if (anyMatches) {
hide = false;
if (this.props.onSelectionChange) {
this.props.onSelectionChange(selectionOffset - 1);
}
}

this.setState({
completions,
Expand All @@ -196,8 +200,8 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
if (completionCount === 0) return; // there are no items to move the selection through

// Note: selectionOffset 0 represents the unsubstituted text, while 1 means first pill selected
const index = (this.state.selectionOffset + delta + completionCount + 1) % (completionCount + 1);
this.setSelection(index);
const index = (this.state.selectionOffset + delta + completionCount - 1) % completionCount;
this.setSelection(1 + index);
}

onEscape(e: KeyboardEvent): boolean {
Expand All @@ -216,7 +220,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
hide = () => {
this.setState({
hide: true,
selectionOffset: 0,
selectionOffset: 1,
completions: [],
completionList: [],
});
Expand All @@ -235,8 +239,13 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
});
}

onConfirmCompletion = () => {
this.onCompletionClicked(this.state.selectionOffset);
}

onCompletionClicked = (selectionOffset: number): boolean => {
if (this.countCompletions() === 0 || selectionOffset === COMPOSER_SELECTED) {
const count = this.countCompletions();
if (count === 0 || selectionOffset < 1 || selectionOffset > count) {
return false;
}

Expand All @@ -249,7 +258,7 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
setSelection(selectionOffset: number) {
this.setState({selectionOffset, hide: false});
if (this.props.onSelectionChange) {
this.props.onSelectionChange(this.state.completionList[selectionOffset - 1], selectionOffset - 1);
this.props.onSelectionChange(selectionOffset - 1);
}
}

Expand Down Expand Up @@ -293,15 +302,15 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {


return completions.length > 0 ? (
<div key={i} className="mx_Autocomplete_ProviderSection">
<div key={i} className="mx_Autocomplete_ProviderSection" role="presentation">
<div className="mx_Autocomplete_provider_name">{ completionResult.provider.getName() }</div>
{ completionResult.provider.renderCompletions(completions) }
</div>
) : null;
}).filter((completion) => !!completion);

return !this.state.hide && renderedCompletions.length > 0 ? (
<div className="mx_Autocomplete" ref={this.containerRef}>
<div id="mx_Autocomplete" className="mx_Autocomplete" ref={this.containerRef} role="listbox">
{ renderedCompletions }
</div>
) : null;
Expand Down
Loading