Merge pull request #67 from matrix-org/kegan/tab-complete
Implement tab complete UX
This commit is contained in:
commit
2a0faea838
6 changed files with 507 additions and 115 deletions
300
src/TabComplete.js
Normal file
300
src/TabComplete.js
Normal file
|
@ -0,0 +1,300 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
var Entry = require("./TabCompleteEntries").Entry;
|
||||
|
||||
const DELAY_TIME_MS = 1000;
|
||||
const KEY_TAB = 9;
|
||||
const KEY_SHIFT = 16;
|
||||
const KEY_WINDOWS = 91;
|
||||
|
||||
// NB: DO NOT USE \b its "words" are roman alphabet only!
|
||||
//
|
||||
// Capturing group containing the start
|
||||
// of line or a whitespace char
|
||||
// \_______________ __________Capturing group of 1 or more non-whitespace chars
|
||||
// _|__ _|_ followed by the end of line
|
||||
// / \/ \
|
||||
const MATCH_REGEX = /(^|\s)(\S+)$/;
|
||||
|
||||
class TabComplete {
|
||||
|
||||
constructor(opts) {
|
||||
opts.startingWordSuffix = opts.startingWordSuffix || "";
|
||||
opts.wordSuffix = opts.wordSuffix || "";
|
||||
opts.allowLooping = opts.allowLooping || false;
|
||||
opts.autoEnterTabComplete = opts.autoEnterTabComplete || false;
|
||||
opts.onClickCompletes = opts.onClickCompletes || false;
|
||||
this.opts = opts;
|
||||
this.completing = false;
|
||||
this.list = []; // full set of tab-completable things
|
||||
this.matchedList = []; // subset of completable things to loop over
|
||||
this.currentIndex = 0; // index in matchedList currently
|
||||
this.originalText = null; // original input text when tab was first hit
|
||||
this.textArea = opts.textArea; // DOMElement
|
||||
this.isFirstWord = false; // true if you tab-complete on the first word
|
||||
this.enterTabCompleteTimerId = null;
|
||||
this.inPassiveMode = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Entry[]} completeList
|
||||
*/
|
||||
setCompletionList(completeList) {
|
||||
this.list = completeList;
|
||||
if (this.opts.onClickCompletes) {
|
||||
// assign onClick listeners for each entry to complete the text
|
||||
this.list.forEach((l) => {
|
||||
l.onClick = () => {
|
||||
this.completeTo(l.getText());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DOMElement}
|
||||
*/
|
||||
setTextArea(textArea) {
|
||||
this.textArea = textArea;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Boolean}
|
||||
*/
|
||||
isTabCompleting() {
|
||||
// actually have things to tab over
|
||||
return this.completing && this.matchedList.length > 1;
|
||||
}
|
||||
|
||||
stopTabCompleting() {
|
||||
this.completing = false;
|
||||
this.currentIndex = 0;
|
||||
this._notifyStateChange();
|
||||
}
|
||||
|
||||
startTabCompleting() {
|
||||
this.completing = true;
|
||||
this.currentIndex = 0;
|
||||
this._calculateCompletions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Do an auto-complete with the given word. This terminates the tab-complete.
|
||||
* @param {string} someVal
|
||||
*/
|
||||
completeTo(someVal) {
|
||||
this.textArea.value = this._replaceWith(someVal, true);
|
||||
this.stopTabCompleting();
|
||||
// keep focus on the text area
|
||||
this.textArea.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Number} numAheadToPeek Return *up to* this many elements.
|
||||
* @return {Entry[]}
|
||||
*/
|
||||
peek(numAheadToPeek) {
|
||||
if (this.matchedList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
var peekList = [];
|
||||
|
||||
// return the current match item and then one with an index higher, and
|
||||
// so on until we've reached the requested limit. If we hit the end of
|
||||
// the list of options we're done.
|
||||
for (var i = 0; i < numAheadToPeek; i++) {
|
||||
var nextIndex;
|
||||
if (this.opts.allowLooping) {
|
||||
nextIndex = (this.currentIndex + i) % this.matchedList.length;
|
||||
}
|
||||
else {
|
||||
nextIndex = this.currentIndex + i;
|
||||
if (nextIndex === this.matchedList.length) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
peekList.push(this.matchedList[nextIndex]);
|
||||
}
|
||||
// console.log("Peek list(%s): %s", numAheadToPeek, JSON.stringify(peekList));
|
||||
return peekList;
|
||||
}
|
||||
|
||||
handleTabPress(passive, shiftKey) {
|
||||
var wasInPassiveMode = this.inPassiveMode && !passive;
|
||||
this.inPassiveMode = passive;
|
||||
|
||||
if (!this.completing) {
|
||||
this.startTabCompleting();
|
||||
}
|
||||
|
||||
if (shiftKey) {
|
||||
this.nextMatchedEntry(-1);
|
||||
}
|
||||
else {
|
||||
// if we were in passive mode we got out of sync by incrementing the
|
||||
// index to show the peek view but not set the text area. Therefore,
|
||||
// we want to set the *current* index rather than the *next* index.
|
||||
this.nextMatchedEntry(wasInPassiveMode ? 0 : 1);
|
||||
}
|
||||
this._notifyStateChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {DOMEvent} e
|
||||
*/
|
||||
onKeyDown(ev) {
|
||||
if (!this.textArea) {
|
||||
console.error("onKeyDown called before a <textarea> was set!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.keyCode !== KEY_TAB) {
|
||||
// pressing any key (except shift, windows, cmd (OSX) and ctrl/alt combinations)
|
||||
// aborts the current tab completion
|
||||
if (this.completing && ev.keyCode !== KEY_SHIFT &&
|
||||
!ev.metaKey && !ev.ctrlKey && !ev.altKey && ev.keyCode !== KEY_WINDOWS) {
|
||||
// they're resuming typing; reset tab complete state vars.
|
||||
this.stopTabCompleting();
|
||||
}
|
||||
|
||||
// pressing any key at all (except tab) restarts the automatic tab-complete timer
|
||||
if (this.opts.autoEnterTabComplete) {
|
||||
clearTimeout(this.enterTabCompleteTimerId);
|
||||
this.enterTabCompleteTimerId = setTimeout(() => {
|
||||
if (!this.completing) {
|
||||
this.handleTabPress(true, false);
|
||||
}
|
||||
}, DELAY_TIME_MS);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// tab key has been pressed at this point
|
||||
this.handleTabPress(false, ev.shiftKey)
|
||||
|
||||
// prevent the default TAB operation (typically focus shifting)
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the textarea to the next value in the matched list.
|
||||
* @param {Number} offset Offset to apply *before* setting the next value.
|
||||
*/
|
||||
nextMatchedEntry(offset) {
|
||||
if (this.matchedList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// work out the new index, wrapping if necessary.
|
||||
this.currentIndex += offset;
|
||||
if (this.currentIndex >= this.matchedList.length) {
|
||||
this.currentIndex = 0;
|
||||
}
|
||||
else if (this.currentIndex < 0) {
|
||||
this.currentIndex = this.matchedList.length - 1;
|
||||
}
|
||||
var isTransitioningToOriginalText = (
|
||||
// impossible to transition if they've never hit tab
|
||||
!this.inPassiveMode && this.currentIndex === 0
|
||||
);
|
||||
|
||||
if (!this.inPassiveMode) {
|
||||
// set textarea to this new value
|
||||
this.textArea.value = this._replaceWith(
|
||||
this.matchedList[this.currentIndex].text,
|
||||
this.currentIndex !== 0 // don't suffix the original text!
|
||||
);
|
||||
}
|
||||
|
||||
// visual display to the user that we looped - TODO: This should be configurable
|
||||
if (isTransitioningToOriginalText) {
|
||||
this.textArea.style["background-color"] = "#faa";
|
||||
setTimeout(() => { // yay for lexical 'this'!
|
||||
this.textArea.style["background-color"] = "";
|
||||
}, 150);
|
||||
|
||||
if (!this.opts.allowLooping) {
|
||||
this.stopTabCompleting();
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.textArea.style["background-color"] = ""; // cancel blinks TODO: required?
|
||||
}
|
||||
}
|
||||
|
||||
_replaceWith(newVal, includeSuffix) {
|
||||
// The regex to replace the input matches a character of whitespace AND
|
||||
// the partial word. If we just use string.replace() with the regex it will
|
||||
// replace the partial word AND the character of whitespace. We want to
|
||||
// preserve whatever that character is (\n, \t, etc) so find out what it is now.
|
||||
var boundaryChar;
|
||||
var res = MATCH_REGEX.exec(this.originalText);
|
||||
if (res) {
|
||||
boundaryChar = res[1]; // the first captured group
|
||||
}
|
||||
if (boundaryChar === undefined) {
|
||||
console.warn("Failed to find boundary char on text: '%s'", this.originalText);
|
||||
boundaryChar = "";
|
||||
}
|
||||
|
||||
var replacementText = (
|
||||
boundaryChar + newVal + (
|
||||
includeSuffix ?
|
||||
(this.isFirstWord ? this.opts.startingWordSuffix : this.opts.wordSuffix) :
|
||||
""
|
||||
)
|
||||
);
|
||||
return this.originalText.replace(MATCH_REGEX, function() {
|
||||
return replacementText; // function form to avoid `$` special-casing
|
||||
});
|
||||
}
|
||||
|
||||
_calculateCompletions() {
|
||||
this.originalText = this.textArea.value; // cache starting text
|
||||
|
||||
// grab the partial word from the text which we'll be tab-completing
|
||||
var res = MATCH_REGEX.exec(this.originalText);
|
||||
if (!res) {
|
||||
this.matchedList = [];
|
||||
return;
|
||||
}
|
||||
// ES6 destructuring; ignore first element (the complete match)
|
||||
var [ , boundaryGroup, partialGroup] = res;
|
||||
this.isFirstWord = partialGroup.length === this.originalText.length;
|
||||
|
||||
this.matchedList = [
|
||||
new Entry(partialGroup) // first entry is always the original partial
|
||||
];
|
||||
|
||||
// find matching entries in the set of entries given to us
|
||||
this.list.forEach((entry) => {
|
||||
if (entry.text.toLowerCase().indexOf(partialGroup.toLowerCase()) === 0) {
|
||||
this.matchedList.push(entry);
|
||||
}
|
||||
});
|
||||
|
||||
// console.log("_calculateCompletions => %s", JSON.stringify(this.matchedList));
|
||||
}
|
||||
|
||||
_notifyStateChange() {
|
||||
if (this.opts.onStateChange) {
|
||||
this.opts.onStateChange(this.completing);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = TabComplete;
|
101
src/TabCompleteEntries.js
Normal file
101
src/TabCompleteEntries.js
Normal file
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
var React = require("react");
|
||||
var sdk = require("./index");
|
||||
|
||||
class Entry {
|
||||
constructor(text) {
|
||||
this.text = text;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string} The text to display in this entry.
|
||||
*/
|
||||
getText() {
|
||||
return this.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {ReactClass} Raw JSX
|
||||
*/
|
||||
getImageJsx() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {?string} The unique key= prop for React dedupe
|
||||
*/
|
||||
getKey() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when this entry is clicked.
|
||||
*/
|
||||
onClick() {
|
||||
// NOP
|
||||
}
|
||||
}
|
||||
|
||||
class MemberEntry extends Entry {
|
||||
constructor(member) {
|
||||
super(member.name || member.userId);
|
||||
this.member = member;
|
||||
}
|
||||
|
||||
getImageJsx() {
|
||||
var MemberAvatar = sdk.getComponent("views.avatars.MemberAvatar");
|
||||
return (
|
||||
<MemberAvatar member={this.member} width={24} height={24} />
|
||||
);
|
||||
}
|
||||
|
||||
getKey() {
|
||||
return this.member.userId;
|
||||
}
|
||||
}
|
||||
|
||||
MemberEntry.fromMemberList = function(members) {
|
||||
return members.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 new MemberEntry(m);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports.Entry = Entry;
|
||||
module.exports.MemberEntry = MemberEntry;
|
|
@ -66,6 +66,7 @@ module.exports.components['views.rooms.RoomHeader'] = require('./components/view
|
|||
module.exports.components['views.rooms.RoomList'] = require('./components/views/rooms/RoomList');
|
||||
module.exports.components['views.rooms.RoomSettings'] = require('./components/views/rooms/RoomSettings');
|
||||
module.exports.components['views.rooms.RoomTile'] = require('./components/views/rooms/RoomTile');
|
||||
module.exports.components['views.rooms.TabCompleteBar'] = require('./components/views/rooms/TabCompleteBar');
|
||||
module.exports.components['views.settings.ChangeAvatar'] = require('./components/views/settings/ChangeAvatar');
|
||||
module.exports.components['views.settings.ChangeDisplayName'] = require('./components/views/settings/ChangeDisplayName');
|
||||
module.exports.components['views.settings.ChangePassword'] = require('./components/views/settings/ChangePassword');
|
||||
|
|
|
@ -33,6 +33,8 @@ var WhoIsTyping = require("../../WhoIsTyping");
|
|||
var Modal = require("../../Modal");
|
||||
var sdk = require('../../index');
|
||||
var CallHandler = require('../../CallHandler');
|
||||
var TabComplete = require("../../TabComplete");
|
||||
var MemberEntry = require("../../TabCompleteEntries").MemberEntry;
|
||||
var Resend = require("../../Resend");
|
||||
var dis = require("../../dispatcher");
|
||||
|
||||
|
@ -76,6 +78,18 @@ module.exports = React.createClass({
|
|||
MatrixClientPeg.get().on("RoomMember.typing", this.onRoomMemberTyping);
|
||||
MatrixClientPeg.get().on("RoomState.members", this.onRoomStateMember);
|
||||
MatrixClientPeg.get().on("sync", this.onSyncStateChange);
|
||||
// xchat-style tab complete, add a colon if tab
|
||||
// completing at the start of the text
|
||||
this.tabComplete = new TabComplete({
|
||||
startingWordSuffix: ": ",
|
||||
wordSuffix: " ",
|
||||
allowLooping: false,
|
||||
autoEnterTabComplete: true,
|
||||
onClickCompletes: true,
|
||||
onStateChange: (isCompleting) => {
|
||||
this.forceUpdate();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
|
@ -225,6 +239,11 @@ module.exports = React.createClass({
|
|||
},
|
||||
|
||||
onRoomStateMember: function(ev, state, member) {
|
||||
if (member.roomId === this.props.roomId) {
|
||||
// a member state changed in this room, refresh the tab complete list
|
||||
this._updateTabCompleteList(this.state.room);
|
||||
}
|
||||
|
||||
if (!this.props.ConferenceHandler) {
|
||||
return;
|
||||
}
|
||||
|
@ -287,6 +306,17 @@ module.exports = React.createClass({
|
|||
|
||||
window.addEventListener('resize', this.onResize);
|
||||
this.onResize();
|
||||
|
||||
this._updateTabCompleteList(this.state.room);
|
||||
},
|
||||
|
||||
_updateTabCompleteList: function(room) {
|
||||
if (!room || !this.tabComplete) {
|
||||
return;
|
||||
}
|
||||
this.tabComplete.setCompletionList(
|
||||
MemberEntry.fromMemberList(room.getJoinedMembers())
|
||||
);
|
||||
},
|
||||
|
||||
_initialiseMessagePanel: function() {
|
||||
|
@ -1154,6 +1184,21 @@ module.exports = React.createClass({
|
|||
</div>
|
||||
);
|
||||
}
|
||||
else if (this.tabComplete.isTabCompleting()) {
|
||||
var TabCompleteBar = sdk.getComponent('rooms.TabCompleteBar');
|
||||
statusBar = (
|
||||
<div className="mx_RoomView_tabCompleteBar">
|
||||
<div className="mx_RoomView_tabCompleteImage">...</div>
|
||||
<div className="mx_RoomView_tabCompleteWrapper">
|
||||
<TabCompleteBar entries={this.tabComplete.peek(6)} />
|
||||
<div className="mx_RoomView_tabCompleteEol">
|
||||
<img src="img/eol.svg" width="22" height="16" alt="->|"/>
|
||||
Auto-complete
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
else if (this.state.hasUnsentMessages) {
|
||||
statusBar = (
|
||||
<div className="mx_RoomView_connectionLostBar">
|
||||
|
@ -1234,7 +1279,9 @@ module.exports = React.createClass({
|
|||
);
|
||||
if (canSpeak) {
|
||||
messageComposer =
|
||||
<MessageComposer room={this.state.room} roomView={this} uploadFile={this.uploadFile} callState={this.state.callState} />
|
||||
<MessageComposer
|
||||
room={this.state.room} roomView={this} uploadFile={this.uploadFile}
|
||||
callState={this.state.callState} tabComplete={this.tabComplete} />
|
||||
}
|
||||
|
||||
// TODO: Why aren't we storing the term/scope/count in this format
|
||||
|
|
|
@ -31,6 +31,7 @@ var MatrixClientPeg = require("../../../MatrixClientPeg");
|
|||
var SlashCommands = require("../../../SlashCommands");
|
||||
var Modal = require("../../../Modal");
|
||||
var CallHandler = require('../../../CallHandler');
|
||||
var MemberEntry = require("../../../TabCompleteEntries").MemberEntry;
|
||||
var sdk = require('../../../index');
|
||||
|
||||
var dis = require("../../../dispatcher");
|
||||
|
@ -64,14 +65,13 @@ function mdownToHtml(mdown) {
|
|||
module.exports = React.createClass({
|
||||
displayName: 'MessageComposer',
|
||||
|
||||
propTypes: {
|
||||
tabComplete: React.PropTypes.any
|
||||
},
|
||||
|
||||
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 +172,9 @@ module.exports = React.createClass({
|
|||
this.props.room.roomId
|
||||
);
|
||||
this.resizeInput();
|
||||
if (this.props.tabComplete) {
|
||||
this.props.tabComplete.setTextArea(this.refs.textarea);
|
||||
}
|
||||
},
|
||||
|
||||
componentWillUnmount: function() {
|
||||
|
@ -197,13 +200,6 @@ module.exports = React.createClass({
|
|||
this.sentHistory.push(input);
|
||||
this.onEnter(ev);
|
||||
}
|
||||
else if (ev.keyCode === KeyCode.TAB) {
|
||||
var members = [];
|
||||
if (this.props.room) {
|
||||
members = this.props.room.getJoinedMembers();
|
||||
}
|
||||
this.onTab(ev, members);
|
||||
}
|
||||
else if (ev.keyCode === KeyCode.UP) {
|
||||
var input = this.refs.textarea.value;
|
||||
var offset = this.refs.textarea.selectionStart || 0;
|
||||
|
@ -222,10 +218,9 @@ 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;
|
||||
|
||||
if (this.props.tabComplete) {
|
||||
this.props.tabComplete.onKeyDown(ev);
|
||||
}
|
||||
|
||||
var self = this;
|
||||
|
@ -349,104 +344,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<sortedMembers.length; i++) {
|
||||
var member = sortedMembers[i];
|
||||
if (member.name && searchIndex < targetIndex) {
|
||||
if (member.name.toLowerCase().indexOf(search[1].toLowerCase()) === 0) {
|
||||
expansion = member.name;
|
||||
searchIndex++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (searchIndex < targetIndex) { // then search raw mxids
|
||||
for (var i=0; i<sortedMembers.length; i++) {
|
||||
if (searchIndex >= 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) {
|
||||
|
|
46
src/components/views/rooms/TabCompleteBar.js
Normal file
46
src/components/views/rooms/TabCompleteBar.js
Normal file
|
@ -0,0 +1,46 @@
|
|||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
var React = require('react');
|
||||
var MatrixClientPeg = require("../../../MatrixClientPeg");
|
||||
|
||||
module.exports = React.createClass({
|
||||
displayName: 'TabCompleteBar',
|
||||
|
||||
propTypes: {
|
||||
entries: React.PropTypes.array.isRequired
|
||||
},
|
||||
|
||||
render: function() {
|
||||
return (
|
||||
<div className="mx_TabCompleteBar">
|
||||
{this.props.entries.map(function(entry, i) {
|
||||
return (
|
||||
<div key={entry.getKey() || i + ""} className="mx_TabCompleteBar_item"
|
||||
onClick={entry.onClick.bind(entry)} >
|
||||
{entry.getImageJsx()}
|
||||
<span className="mx_TabCompleteBar_text">
|
||||
{entry.getText()}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
Loading…
Reference in a new issue