diff --git a/src/TabComplete.js b/src/TabComplete.js new file mode 100644 index 0000000000..5a6bfa8343 --- /dev/null +++ b/src/TabComplete.js @@ -0,0 +1,151 @@ +/* +Copyright 2015 OpenMarket Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +const DELAY_TIME_MS = 500; +const KEY_TAB = 9; +const KEY_SHIFT = 16; + +class TabComplete { + + constructor(opts) { + opts.startingWordSuffix = opts.startingWordSuffix || ""; + opts.wordSuffix = opts.wordSuffix || ""; + this.opts = opts; + + this.tabStruct = { + completing: false, + original: null, + index: 0 + }; + this.list = []; + this.textArea = opts.textArea; + } + + /** + * @param {String[]} completeList + */ + setCompletionList(completeList) { + this.list = completeList; + } + + setTextArea(textArea) { + this.textArea = textArea; + } + + next() { + this.tabStruct.index++; + this.setCompletionOption(); + } + + prev() { + this.tabStruct.index --; + if (this.tabStruct.index < 0) { + // wrap to the last search match, and fix up to a real index + // value after we've matched. + this.tabStruct.index = Number.MAX_VALUE; + } + this.setCompletionOption(); + } + + setCompletionOption() { + var searchIndex = 0; + var targetIndex = this.tabStruct.index; + var text = this.tabStruct.original; + + var search = /@?([a-zA-Z0-9_\-:\.]+)$/.exec(text); + // console.log("Searched in '%s' - got %s", text, search); + if (targetIndex === 0) { // 0 is always the original text + this.textArea.value = text; + } + else if (search && search[1]) { + // console.log("search found: " + search+" from "+text); + var expansion; + + // FIXME: could do better than linear search here + for (var i=0; i < this.list.length; i++) { + if (searchIndex < targetIndex) { + if (this.list[i].toLowerCase().indexOf(search[1].toLowerCase()) === 0) { + expansion = this.list[i]; + searchIndex++; + } + } + } + + if (searchIndex === targetIndex || targetIndex === Number.MAX_VALUE) { + if (search[0].length === text.length) { + expansion += this.opts.startingWordSuffix; + } + else { + expansion += this.opts.wordSuffix; + } + this.textArea.value = text.replace( + /@?([a-zA-Z0-9_\-:\.]+)$/, expansion + ); + // cancel blink + this.textArea.style["background-color"] = ""; + if (targetIndex === Number.MAX_VALUE) { + // wrap the index around to the last index found + this.tabStruct.index = searchIndex; + targetIndex = searchIndex; + } + } + else { + // console.log("wrapped!"); + this.textArea.style["background-color"] = "#faa"; + setTimeout(() => { // yay for lexical 'this'! + this.textArea.style["background-color"] = ""; + }, 150); + this.textArea.value = text; + this.tabStruct.index = 0; + } + } + else { + this.tabStruct.index = 0; + } + } + + onKeyDown(ev) { + if (ev.keyCode !== KEY_TAB) { + if (ev.keyCode !== KEY_SHIFT && this.tabStruct.completing) { + // they're resuming typing; reset tab complete state vars. + this.tabStruct.completing = false; + this.tabStruct.index = 0; + } + return false; + } + // init struct if necessary + if (!this.tabStruct.completing) { + this.tabStruct.completing = true; + this.tabStruct.index = 0; + // cache starting text + this.tabStruct.original = this.textArea.value; + } + + if (ev.shiftKey) { + this.prev(); + } + else { + this.next(); + } + // prevent the default TAB operation (typically focus shifting) + ev.preventDefault(); + return true; + } + +}; + + +module.exports = TabComplete; diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 7c228b5c9d..3978ef2154 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -31,6 +31,7 @@ var MatrixClientPeg = require("../../../MatrixClientPeg"); var SlashCommands = require("../../../SlashCommands"); var Modal = require("../../../Modal"); var CallHandler = require('../../../CallHandler'); +var TabComplete = require("../../../TabComplete"); var sdk = require('../../../index'); var dis = require("../../../dispatcher"); @@ -67,11 +68,6 @@ module.exports = React.createClass({ componentWillMount: function() { this.oldScrollHeight = 0; this.markdownEnabled = MARKDOWN_ENABLED; - this.tabStruct = { - completing: false, - original: null, - index: 0 - }; var self = this; this.sentHistory = { // The list of typed messages. Index 0 is more recent @@ -172,6 +168,14 @@ module.exports = React.createClass({ this.props.room.roomId ); this.resizeInput(); + + // xchat-style tab complete, add a colon if tab + // completing at the start of the text + this._tabComplete = new TabComplete({ + textArea: this.refs.textarea, + startingWordSuffix: ": ", + wordSuffix: " " + }); }, componentWillUnmount: function() { @@ -198,11 +202,38 @@ module.exports = React.createClass({ this.onEnter(ev); } else if (ev.keyCode === KeyCode.TAB) { - var members = []; + var memberList = []; if (this.props.room) { - members = this.props.room.getJoinedMembers(); + // TODO: We should cache this list and only update it when the + // member list changes + memberList = this.props.room.getJoinedMembers().sort(function(a, b) { + var userA = a.user; + var userB = b.user; + if (userA && !userB) { + return -1; // a comes first + } + else if (!userA && userB) { + return 1; // b comes first + } + else if (!userA && !userB) { + return 0; // don't care + } + else { // both User objects exist + if (userA.lastActiveAgo < userB.lastActiveAgo) { + return -1; // a comes first + } + else if (userA.lastActiveAgo > userB.lastActiveAgo) { + return 1; // b comes first + } + else { + return 0; // same last active ago + } + } + }).map(function(m) { + return m.name || m.userId; + }); } - this.onTab(ev, members); + this._tabComplete.setCompletionList(memberList); } else if (ev.keyCode === KeyCode.UP) { var input = this.refs.textarea.value; @@ -222,11 +253,7 @@ module.exports = React.createClass({ this.resizeInput(); } } - else if (ev.keyCode !== KeyCode.SHIFT && this.tabStruct.completing) { - // they're resuming typing; reset tab complete state vars. - this.tabStruct.completing = false; - this.tabStruct.index = 0; - } + this._tabComplete.onKeyDown(ev); var self = this; setTimeout(function() { @@ -346,104 +373,6 @@ module.exports = React.createClass({ ev.preventDefault(); }, - onTab: function(ev, sortedMembers) { - var textArea = this.refs.textarea; - if (!this.tabStruct.completing) { - this.tabStruct.completing = true; - this.tabStruct.index = 0; - // cache starting text - this.tabStruct.original = textArea.value; - } - - // loop in the right direction - if (ev.shiftKey) { - this.tabStruct.index --; - if (this.tabStruct.index < 0) { - // wrap to the last search match, and fix up to a real index - // value after we've matched. - this.tabStruct.index = Number.MAX_VALUE; - } - } - else { - this.tabStruct.index++; - } - - var searchIndex = 0; - var targetIndex = this.tabStruct.index; - var text = this.tabStruct.original; - - var search = /@?([a-zA-Z0-9_\-:\.]+)$/.exec(text); - // console.log("Searched in '%s' - got %s", text, search); - if (targetIndex === 0) { // 0 is always the original text - textArea.value = text; - } - else if (search && search[1]) { - // console.log("search found: " + search+" from "+text); - var expansion; - - // FIXME: could do better than linear search here - for (var i=0; i= targetIndex) { - break; - } - var userId = sortedMembers[i].userId; - // === 1 because mxids are @username - if (userId.toLowerCase().indexOf(search[1].toLowerCase()) === 1) { - expansion = userId; - searchIndex++; - } - } - } - - if (searchIndex === targetIndex || - targetIndex === Number.MAX_VALUE) { - // xchat-style tab complete, add a colon if tab - // completing at the start of the text - if (search[0].length === text.length) { - expansion += ": "; - } - else { - expansion += " "; - } - textArea.value = text.replace( - /@?([a-zA-Z0-9_\-:\.]+)$/, expansion - ); - // cancel blink - textArea.style["background-color"] = ""; - if (targetIndex === Number.MAX_VALUE) { - // wrap the index around to the last index found - this.tabStruct.index = searchIndex; - targetIndex = searchIndex; - } - } - else { - // console.log("wrapped!"); - textArea.style["background-color"] = "#faa"; - setTimeout(function() { - textArea.style["background-color"] = ""; - }, 150); - textArea.value = text; - this.tabStruct.index = 0; - } - } - else { - this.tabStruct.index = 0; - } - // prevent the default TAB operation (typically focus shifting) - ev.preventDefault(); - }, - onTypingActivity: function() { this.isTyping = true; if (!this.userTypingTimer) {