diff --git a/src/RichText.js b/src/RichText.js index 7e749bc24a..678a7de190 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -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): {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 + } +} diff --git a/src/autocomplete/AutocompleteProvider.js b/src/autocomplete/AutocompleteProvider.js index 61158d2b56..f741e085b0 100644 --- a/src/autocomplete/AutocompleteProvider.js +++ b/src/autocomplete/AutocompleteProvider.js @@ -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 { + 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'; } diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index 95669d5e0f..6b66d2fbdc 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -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 }; }); diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index 7b950c0ed0..30b448d7f2 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -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, diff --git a/src/autocomplete/DuckDuckGoProvider.js b/src/autocomplete/DuckDuckGoProvider.js index 496ce72e46..b2bf27a21a 100644 --- a/src/autocomplete/DuckDuckGoProvider.js +++ b/src/autocomplete/DuckDuckGoProvider.js @@ -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()) diff --git a/src/autocomplete/EmojiProvider.js b/src/autocomplete/EmojiProvider.js index 684414d72a..e1b5f3ea38 100644 --- a/src/autocomplete/EmojiProvider.js +++ b/src/autocomplete/EmojiProvider.js @@ -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 { diff --git a/src/autocomplete/RoomProvider.js b/src/autocomplete/RoomProvider.js index b1232358b5..8b5650e7a7 100644 --- a/src/autocomplete/RoomProvider.js +++ b/src/autocomplete/RoomProvider.js @@ -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 diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 51a85adaf1..3edb2bf00c 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -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 diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 0218a88195..babd349c31 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -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) { diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 5373ca4dc8..ce1ced2b59 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -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 (
- +
diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index d82e9fb6c7..9b615e7e4e 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -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())); } }