Permalink
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Browse files
Hide/show autocomplete based on selection state
- Loading branch information
|
@@ -5,7 +5,8 @@ import { |
|
|
convertFromHTML, |
|
|
DefaultDraftBlockRenderMap, |
|
|
DefaultDraftInlineStyle, |
|
|
CompositeDecorator |
|
|
CompositeDecorator, |
|
|
SelectionState |
|
|
} from 'draft-js'; |
|
|
import * as sdk from './index'; |
|
|
|
|
@@ -168,3 +169,28 @@ export function modifyText(contentState: ContentState, rangeToReplace: Selection |
|
|
|
|
|
return Modifier.replaceText(contentState, rangeToReplace, modifyFn(text), inlineStyle, entityKey); |
|
|
} |
|
|
|
|
|
/** |
|
|
* Computes the plaintext offsets of the given SelectionState. |
|
|
* Note that this inherently means we make assumptions about what that means (no separator between ContentBlocks, etc) |
|
|
* Used by autocomplete to show completions when the current selection lies within, or at the edges of a command. |
|
|
*/ |
|
|
export function getTextSelectionOffsets(selectionState: SelectionState, |
|
|
contentBlocks: Array<ContentBlock>): {start: number, end: number} { |
|
|
let offset = 0, start = 0, end = 0; |
|
|
for(let block of contentBlocks) { |
|
|
if (selectionState.getStartKey() == block.getKey()) { |
|
|
start = offset + selectionState.getStartOffset(); |
|
|
} |
|
|
if (selectionState.getEndKey() == block.getKey()) { |
|
|
end = offset + selectionState.getEndOffset(); |
|
|
break; |
|
|
} |
|
|
offset += block.getLength(); |
|
|
} |
|
|
|
|
|
return { |
|
|
start, |
|
|
end |
|
|
} |
|
|
} |
|
|
@@ -1,4 +1,41 @@ |
|
|
import Q from 'q'; |
|
|
|
|
|
export default class AutocompleteProvider { |
|
|
constructor(commandRegex?: RegExp, fuseOpts?: any) { |
|
|
if(commandRegex) { |
|
|
if(!commandRegex.global) { |
|
|
throw new Error('commandRegex must have global flag set'); |
|
|
} |
|
|
this.commandRegex = commandRegex; |
|
|
} |
|
|
} |
|
|
|
|
|
/** |
|
|
* 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) |
|
|
return null; |
|
|
|
|
|
let match = null; |
|
|
while((match = this.commandRegex.exec(query)) != null) { |
|
|
let matchStart = match.index, |
|
|
matchEnd = matchStart + match[0].length; |
|
|
|
|
|
console.log(match); |
|
|
|
|
|
if(selection.start <= matchEnd && selection.end >= matchStart) { |
|
|
return match; |
|
|
} |
|
|
} |
|
|
this.commandRegex.lastIndex = 0; |
|
|
return null; |
|
|
} |
|
|
|
|
|
getCompletions(query: String, selection: {start: number, end: number}) { |
|
|
return Q.when([]); |
|
|
} |
|
|
|
|
|
getName(): string { |
|
|
return 'Default Provider'; |
|
|
} |
|
|
|
@@ -12,10 +12,10 @@ const PROVIDERS = [ |
|
|
EmojiProvider |
|
|
].map(completer => completer.getInstance()); |
|
|
|
|
|
export function getCompletions(query: String) { |
|
|
export function getCompletions(query: string, selection: {start: number, end: number}) { |
|
|
return PROVIDERS.map(provider => { |
|
|
return { |
|
|
completions: provider.getCompletions(query), |
|
|
completions: provider.getCompletions(query, selection), |
|
|
provider |
|
|
}; |
|
|
}); |
|
|
|
@@ -39,22 +39,23 @@ const COMMANDS = [ |
|
|
} |
|
|
]; |
|
|
|
|
|
let COMMAND_RE = /(^\/\w*)/g; |
|
|
|
|
|
let instance = null; |
|
|
|
|
|
export default class CommandProvider extends AutocompleteProvider { |
|
|
constructor() { |
|
|
super(); |
|
|
super(COMMAND_RE); |
|
|
this.fuse = new Fuse(COMMANDS, { |
|
|
keys: ['command', 'args', 'description'] |
|
|
}); |
|
|
} |
|
|
|
|
|
getCompletions(query: String) { |
|
|
getCompletions(query: string, selection: {start: number, end: number}) { |
|
|
let completions = []; |
|
|
const matches = query.match(/(^\/\w*)/); |
|
|
if(!!matches) { |
|
|
const command = matches[0]; |
|
|
completions = this.fuse.search(command).map(result => { |
|
|
const command = this.getCurrentCommand(query, selection); |
|
|
if(command) { |
|
|
completions = this.fuse.search(command[0]).map(result => { |
|
|
return { |
|
|
title: result.command, |
|
|
subtitle: result.args, |
|
|
|
@@ -2,23 +2,27 @@ import AutocompleteProvider from './AutocompleteProvider'; |
|
|
import Q from 'q'; |
|
|
import 'whatwg-fetch'; |
|
|
|
|
|
const DDG_REGEX = /\/ddg\s+(.+)$/; |
|
|
const DDG_REGEX = /\/ddg\s+(.+)$/g; |
|
|
const REFERER = 'vector'; |
|
|
|
|
|
let instance = null; |
|
|
|
|
|
export default class DuckDuckGoProvider extends AutocompleteProvider { |
|
|
constructor() { |
|
|
super(DDG_REGEX); |
|
|
} |
|
|
|
|
|
static getQueryUri(query: String) { |
|
|
return `http://api.duckduckgo.com/?q=${encodeURIComponent(query)}` |
|
|
+ `&format=json&no_redirect=1&no_html=1&t=${encodeURIComponent(REFERER)}`; |
|
|
} |
|
|
|
|
|
getCompletions(query: String) { |
|
|
let match = DDG_REGEX.exec(query); |
|
|
if(!query || !match) |
|
|
getCompletions(query: string, selection: {start: number, end: number}) { |
|
|
let command = this.getCurrentCommand(query, selection); |
|
|
if(!query || !command) |
|
|
return Q.when([]); |
|
|
|
|
|
return fetch(DuckDuckGoProvider.getQueryUri(match[1]), { |
|
|
return fetch(DuckDuckGoProvider.getQueryUri(command[1]), { |
|
|
method: 'GET' |
|
|
}) |
|
|
.then(response => response.json()) |
|
|
|
@@ -10,16 +10,15 @@ let instance = null; |
|
|
|
|
|
export default class EmojiProvider extends AutocompleteProvider { |
|
|
constructor() { |
|
|
super(); |
|
|
super(EMOJI_REGEX); |
|
|
this.fuse = new Fuse(EMOJI_SHORTNAMES); |
|
|
} |
|
|
|
|
|
getCompletions(query: String) { |
|
|
getCompletions(query: string, selection: {start: number, end: number}) { |
|
|
let completions = []; |
|
|
let matches = query.match(EMOJI_REGEX); |
|
|
let command = matches && matches[0]; |
|
|
let command = this.getCurrentCommand(query, selection); |
|
|
if(command) { |
|
|
completions = this.fuse.search(command).map(result => { |
|
|
completions = this.fuse.search(command[0]).map(result => { |
|
|
let shortname = EMOJI_SHORTNAMES[result]; |
|
|
let imageHTML = shortnameToImage(shortname); |
|
|
return { |
|
|
|
@@ -9,17 +9,18 @@ let instance = null; |
|
|
|
|
|
export default class RoomProvider extends AutocompleteProvider { |
|
|
constructor() { |
|
|
super(); |
|
|
super(ROOM_REGEX, { |
|
|
keys: ['displayName', 'userId'] |
|
|
}); |
|
|
this.fuse = new Fuse([], { |
|
|
keys: ['name', 'roomId', 'aliases'] |
|
|
}); |
|
|
} |
|
|
|
|
|
getCompletions(query: String) { |
|
|
getCompletions(query: string, selection: {start: number, end: number}) { |
|
|
let client = MatrixClientPeg.get(); |
|
|
let completions = []; |
|
|
const matches = query.match(ROOM_REGEX); |
|
|
const command = matches && matches[0]; |
|
|
const command = this.getCurrentCommand(query, selection); |
|
|
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 => { |
|
@@ -29,7 +30,7 @@ export default class RoomProvider extends AutocompleteProvider { |
|
|
aliases: room.getAliases() |
|
|
}; |
|
|
})); |
|
|
completions = this.fuse.search(command).map(room => { |
|
|
completions = this.fuse.search(command[0]).map(room => { |
|
|
return { |
|
|
title: room.name, |
|
|
subtitle: room.roomId |
|
|
|
@@ -2,26 +2,27 @@ import AutocompleteProvider from './AutocompleteProvider'; |
|
|
import Q from 'q'; |
|
|
import Fuse from 'fuse.js'; |
|
|
|
|
|
const ROOM_REGEX = /@[^\s]*/g; |
|
|
const USER_REGEX = /@[^\s]*/g; |
|
|
|
|
|
let instance = null; |
|
|
|
|
|
export default class UserProvider extends AutocompleteProvider { |
|
|
constructor() { |
|
|
super(); |
|
|
super(USER_REGEX, { |
|
|
keys: ['displayName', 'userId'] |
|
|
}); |
|
|
this.users = []; |
|
|
this.fuse = new Fuse([], { |
|
|
keys: ['displayName', 'userId'] |
|
|
}) |
|
|
} |
|
|
|
|
|
getCompletions(query: String) { |
|
|
getCompletions(query: string, selection: {start: number, end: number}) { |
|
|
let completions = []; |
|
|
let matches = query.match(ROOM_REGEX); |
|
|
let command = matches && matches[0]; |
|
|
let command = this.getCurrentCommand(query, selection); |
|
|
if(command) { |
|
|
this.fuse.set(this.users); |
|
|
completions = this.fuse.search(command).map(user => { |
|
|
completions = this.fuse.search(command[0]).map(user => { |
|
|
return { |
|
|
title: user.displayName || user.userId, |
|
|
description: user.userId |
|
|
|
@@ -7,27 +7,27 @@ export default class Autocomplete extends React.Component { |
|
|
constructor(props) { |
|
|
super(props); |
|
|
this.state = { |
|
|
completions: [] |
|
|
completions: [], |
|
|
|
|
|
// how far down the completion list we are |
|
|
selectionOffset: 0 |
|
|
}; |
|
|
} |
|
|
|
|
|
componentWillReceiveProps(props, state) { |
|
|
if(props.query == this.props.query) return; |
|
|
|
|
|
getCompletions(props.query).map(completionResult => { |
|
|
getCompletions(props.query, props.selection).map(completionResult => { |
|
|
try { |
|
|
// console.log(`${completionResult.provider.getName()}: ${JSON.stringify(completionResult.completions)}`); |
|
|
completionResult.completions.then(completions => { |
|
|
let i = this.state.completions.findIndex( |
|
|
completion => completion.provider === completionResult.provider |
|
|
); |
|
|
|
|
|
i = i == -1 ? this.state.completions.length : i; |
|
|
// console.log(completionResult); |
|
|
let newCompletions = Object.assign([], this.state.completions); |
|
|
completionResult.completions = completions; |
|
|
newCompletions[i] = completionResult; |
|
|
// console.log(newCompletions); |
|
|
this.setState({ |
|
|
completions: newCompletions |
|
|
}); |
|
@@ -42,8 +42,7 @@ export default class Autocomplete extends React.Component { |
|
|
} |
|
|
|
|
|
render() { |
|
|
const renderedCompletions = this.state.completions.map((completionResult, i) => { |
|
|
// console.log(completionResult); |
|
|
let renderedCompletions = this.state.completions.map((completionResult, i) => { |
|
|
let completions = completionResult.completions.map((completion, i) => { |
|
|
let Component = completion.component; |
|
|
if(Component) { |
|
|
|
@@ -36,7 +36,8 @@ export default class MessageComposer extends React.Component { |
|
|
this.onInputContentChanged = this.onInputContentChanged.bind(this); |
|
|
|
|
|
this.state = { |
|
|
autocompleteQuery: '' |
|
|
autocompleteQuery: '', |
|
|
selection: null |
|
|
}; |
|
|
} |
|
|
|
|
@@ -121,11 +122,11 @@ export default class MessageComposer extends React.Component { |
|
|
}); |
|
|
} |
|
|
|
|
|
onInputContentChanged(content: string) { |
|
|
onInputContentChanged(content: string, selection: {start: number, end: number}) { |
|
|
this.setState({ |
|
|
autocompleteQuery: content |
|
|
autocompleteQuery: content, |
|
|
selection |
|
|
}); |
|
|
console.log(content); |
|
|
} |
|
|
|
|
|
render() { |
|
@@ -200,7 +201,7 @@ export default class MessageComposer extends React.Component { |
|
|
return ( |
|
|
<div className="mx_MessageComposer mx_fadable" style={{ opacity: this.props.opacity }}> |
|
|
<div className="mx_MessageComposer_autocomplete_wrapper"> |
|
|
<Autocomplete query={this.state.autocompleteQuery} /> |
|
|
<Autocomplete query={this.state.autocompleteQuery} selection={this.state.selection} /> |
|
|
</div> |
|
|
<div className="mx_MessageComposer_wrapper"> |
|
|
<div className="mx_MessageComposer_row"> |
|
|
|
@@ -352,7 +352,9 @@ export default class MessageComposerInput extends React.Component { |
|
|
} |
|
|
|
|
|
if(this.props.onContentChanged) { |
|
|
this.props.onContentChanged(editorState.getCurrentContent().getPlainText()); |
|
|
this.props.onContentChanged(editorState.getCurrentContent().getPlainText(), |
|
|
RichText.getTextSelectionOffsets(editorState.getSelection(), |
|
|
editorState.getCurrentContent().getBlocksAsArray())); |
|
|
} |
|
|
} |
|
|
|
|
|