Merge pull request #3349 from matrix-org/bwindels/tab-complete-name

New composer: support forcing auto complete on name by hitting tab
This commit is contained in:
Bruno Windels 2019-08-28 16:17:16 +00:00 committed by GitHub
commit f119ac4b22
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 191 additions and 46 deletions

View file

@ -27,6 +27,15 @@ limitations under the License.
white-space: nowrap; white-space: nowrap;
} }
@keyframes visualbell {
from { background-color: $visual-bell-bg-color; }
to { background-color: $primary-bg-color; }
}
&.mx_BasicMessageComposer_input_error {
animation: 0.2s visualbell;
}
.mx_BasicMessageComposer_input { .mx_BasicMessageComposer_input {
white-space: pre-wrap; white-space: pre-wrap;
word-wrap: break-word; word-wrap: break-word;

View file

@ -129,7 +129,7 @@ limitations under the License.
} }
@keyframes visualbell { @keyframes visualbell {
from { background-color: #faa; } from { background-color: $visual-bell-bg-color; }
to { background-color: $primary-bg-color; } to { background-color: $primary-bg-color; }
} }

View file

@ -146,6 +146,8 @@ $button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
$button-link-fg-color: $accent-color; $button-link-fg-color: $accent-color;
$button-link-bg-color: transparent; $button-link-bg-color: transparent;
$visual-bell-bg-color: #800;
$room-warning-bg-color: $header-panel-bg-color; $room-warning-bg-color: $header-panel-bg-color;
$dark-panel-bg-color: $header-panel-bg-color; $dark-panel-bg-color: $header-panel-bg-color;

View file

@ -247,6 +247,8 @@ $button-danger-disabled-bg-color: #f5b6bb; // TODO: Verify color
$button-link-fg-color: $accent-color; $button-link-fg-color: $accent-color;
$button-link-bg-color: transparent; $button-link-bg-color: transparent;
$visual-bell-bg-color: #faa;
// Toggle switch // Toggle switch
$togglesw-off-color: #c1c9d6; $togglesw-off-color: #c1c9d6;
$togglesw-on-color: $accent-color; $togglesw-on-color: $accent-color;

View file

@ -14,6 +14,8 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import classNames from 'classnames';
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import EditorModel from '../../../editor/model'; import EditorModel from '../../../editor/model';
@ -75,10 +77,10 @@ export default class BasicMessageEditor extends React.Component {
this._modifiedFlag = false; this._modifiedFlag = false;
} }
_replaceEmoticon = (caret, inputType, diff) => { _replaceEmoticon = (caretPosition, inputType, diff) => {
const {model} = this.props; const {model} = this.props;
const range = model.startRange(caret); const range = model.startRange(caretPosition);
// expand range max 8 characters backwards from caret, // expand range max 8 characters backwards from caretPosition,
// as a space to look for an emoticon // as a space to look for an emoticon
let n = 8; let n = 8;
range.expandBackwardsWhile((index, offset) => { range.expandBackwardsWhile((index, offset) => {
@ -91,6 +93,7 @@ export default class BasicMessageEditor extends React.Component {
const query = emoticonMatch[1].toLowerCase().replace("-", ""); const query = emoticonMatch[1].toLowerCase().replace("-", "");
const data = EMOJIBASE.find(e => e.emoticon ? e.emoticon.toLowerCase() === query : false); const data = EMOJIBASE.find(e => e.emoticon ? e.emoticon.toLowerCase() === query : false);
if (data) { if (data) {
const {partCreator} = model;
const hasPrecedingSpace = emoticonMatch[0][0] === " "; const hasPrecedingSpace = emoticonMatch[0][0] === " ";
// we need the range to only comprise of the emoticon // we need the range to only comprise of the emoticon
// because we'll replace the whole range with an emoji, // because we'll replace the whole range with an emoji,
@ -99,7 +102,7 @@ export default class BasicMessageEditor extends React.Component {
range.moveStart(emoticonMatch.index + (hasPrecedingSpace ? 1 : 0)); range.moveStart(emoticonMatch.index + (hasPrecedingSpace ? 1 : 0));
// this returns the amount of added/removed characters during the replace // this returns the amount of added/removed characters during the replace
// so the caret position can be adjusted. // so the caret position can be adjusted.
return range.replace([this.props.model.partCreator.plain(data.unicode + " ")]); return range.replace([partCreator.plain(data.unicode + " ")]);
} }
} }
} }
@ -160,7 +163,7 @@ export default class BasicMessageEditor extends React.Component {
} }
_refreshLastCaretIfNeeded() { _refreshLastCaretIfNeeded() {
// TODO: needed when going up and down in editing messages ... not sure why yet // XXX: needed when going up and down in editing messages ... not sure why yet
// because the editors should stop doing this when when blurred ... // because the editors should stop doing this when when blurred ...
// maybe it's on focus and the _editorRef isn't available yet or something. // maybe it's on focus and the _editorRef isn't available yet or something.
if (!this._editorRef) { if (!this._editorRef) {
@ -269,6 +272,9 @@ export default class BasicMessageEditor extends React.Component {
default: default:
return; // don't preventDefault on anything else return; // don't preventDefault on anything else
} }
} else if (event.key === "Tab") {
this._tabCompleteName();
handled = true;
} }
} }
if (handled) { if (handled) {
@ -277,6 +283,32 @@ export default class BasicMessageEditor extends React.Component {
} }
} }
async _tabCompleteName() {
try {
await new Promise(resolve => this.setState({showVisualBell: false}, resolve));
const {model} = this.props;
const caret = this.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
const range = model.startRange(position);
range.expandBackwardsWhile((index, offset, part) => {
return part.text[offset] !== " " && (part.type === "plain" || part.type === "pill-candidate");
});
const {partCreator} = model;
// await for auto-complete to be open
await model.transform(() => {
const addedLen = range.replace([partCreator.pillCandidate(range.text)]);
return model.positionForOffset(caret.offset + addedLen, true);
});
await model.autoComplete.onTab();
if (!model.autoComplete.hasSelection()) {
this.setState({showVisualBell: true});
model.autoComplete.close();
}
} catch (err) {
console.error(err);
}
}
isModified() { isModified() {
return this._modifiedFlag; return this._modifiedFlag;
} }
@ -304,7 +336,7 @@ export default class BasicMessageEditor extends React.Component {
// not really, but we could not serialize the parts, and just change the autoCompleter // not really, but we could not serialize the parts, and just change the autoCompleter
partCreator.setAutoCompleteCreator(autoCompleteCreator( partCreator.setAutoCompleteCreator(autoCompleteCreator(
() => this._autocompleteRef, () => this._autocompleteRef,
query => this.setState({query}), query => new Promise(resolve => this.setState({query}, resolve)),
)); ));
this.historyManager = new HistoryManager(partCreator); this.historyManager = new HistoryManager(partCreator);
// initial render of model // initial render of model
@ -345,7 +377,10 @@ export default class BasicMessageEditor extends React.Component {
/> />
</div>); </div>);
} }
return (<div className="mx_BasicMessageComposer"> const classes = classNames("mx_BasicMessageComposer", {
"mx_BasicMessageComposer_input_error": this.state.showVisualBell,
});
return (<div className={classes}>
{ autoComplete } { autoComplete }
<div <div
className="mx_BasicMessageComposer_input" className="mx_BasicMessageComposer_input"

View file

@ -279,22 +279,33 @@ export default class SendMessageComposer extends React.Component {
}; };
_insertMention(userId) { _insertMention(userId) {
const {model} = this;
const {partCreator} = model;
const member = this.props.room.getMember(userId); const member = this.props.room.getMember(userId);
const displayName = member ? const displayName = member ?
member.rawDisplayName : userId; member.rawDisplayName : userId;
const userPillPart = this.model.partCreator.userPill(displayName, userId); const userPillPart = partCreator.userPill(displayName, userId);
this.model.insertPartsAt([userPillPart], this._editorRef.getCaret()); const caret = this._editorRef.getCaret();
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
model.transform(() => {
const addedLen = model.insert([userPillPart], position);
return model.positionForOffset(caret.offset + addedLen, true);
});
// refocus on composer, as we just clicked "Mention" // refocus on composer, as we just clicked "Mention"
this._editorRef && this._editorRef.focus(); this._editorRef && this._editorRef.focus();
} }
_insertQuotedMessage(event) { _insertQuotedMessage(event) {
const {partCreator} = this.model; const {model} = this;
const {partCreator} = model;
const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true }); const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true });
// add two newlines // add two newlines
quoteParts.push(partCreator.newline()); quoteParts.push(partCreator.newline());
quoteParts.push(partCreator.newline()); quoteParts.push(partCreator.newline());
this.model.insertPartsAt(quoteParts, {offset: 0}); model.transform(() => {
const addedLen = model.insert(quoteParts, model.positionForOffset(0));
return model.positionForOffset(addedLen, true);
});
// refocus on composer, as we just clicked "Quote" // refocus on composer, as we just clicked "Quote"
this._editorRef && this._editorRef.focus(); this._editorRef && this._editorRef.focus();
} }

View file

@ -33,6 +33,10 @@ export default class AutocompleteWrapperModel {
}); });
} }
close() {
this._updateCallback({close: true});
}
hasSelection() { hasSelection() {
return this._getAutocompleterComponent().hasSelection(); return this._getAutocompleterComponent().hasSelection();
} }
@ -52,9 +56,6 @@ export default class AutocompleteWrapperModel {
} else { } else {
await acComponent.moveSelection(e.shiftKey ? -1 : +1); await acComponent.moveSelection(e.shiftKey ? -1 : +1);
} }
this._updateCallback({
close: true,
});
} }
onUpArrow() { onUpArrow() {
@ -70,7 +71,7 @@ export default class AutocompleteWrapperModel {
// so we can restore it in onComponentSelectionChange when the value is undefined (meaning it should be the typed text) // so we can restore it in onComponentSelectionChange when the value is undefined (meaning it should be the typed text)
this._queryPart = part; this._queryPart = part;
this._queryOffset = offset; this._queryOffset = offset;
this._updateQuery(part.text); return this._updateQuery(part.text);
} }
onComponentSelectionChange(completion) { onComponentSelectionChange(completion) {

View file

@ -35,6 +35,11 @@ import Range from "./range";
* This is used to adjust the caret position. * This is used to adjust the caret position.
*/ */
/**
* @callback ManualTransformCallback
* @return the caret position
*/
export default class EditorModel { export default class EditorModel {
constructor(parts, partCreator, updateCallback = null) { constructor(parts, partCreator, updateCallback = null) {
this._parts = parts; this._parts = parts;
@ -44,7 +49,6 @@ export default class EditorModel {
this._autoCompletePartIdx = null; this._autoCompletePartIdx = null;
this._transformCallback = null; this._transformCallback = null;
this.setUpdateCallback(updateCallback); this.setUpdateCallback(updateCallback);
this._updateInProgress = false;
} }
/** /**
@ -90,10 +94,14 @@ export default class EditorModel {
_removePart(index) { _removePart(index) {
this._parts.splice(index, 1); this._parts.splice(index, 1);
if (this._activePartIdx >= index) { if (index === this._activePartIdx) {
this._activePartIdx = null;
} else if (this._activePartIdx > index) {
--this._activePartIdx; --this._activePartIdx;
} }
if (this._autoCompletePartIdx >= index) { if (index === this._autoCompletePartIdx) {
this._autoCompletePartIdx = null;
} else if (this._autoCompletePartIdx > index) {
--this._autoCompletePartIdx; --this._autoCompletePartIdx;
} }
} }
@ -150,8 +158,14 @@ export default class EditorModel {
this._updateCallback(caret, inputType); this._updateCallback(caret, inputType);
} }
insertPartsAt(parts, caret) { /**
const position = this.positionForOffset(caret.offset, caret.atNodeEnd); * Inserts the given parts at the given position.
* Should be run inside a `model.transform()` callback.
* @param {Part[]} parts the parts to replace the range with
* @param {DocumentPosition} position the position to start inserting at
* @return {Number} the amount of characters added
*/
insert(parts, position) {
const insertIndex = this._splitAt(position); const insertIndex = this._splitAt(position);
let newTextLength = 0; let newTextLength = 0;
for (let i = 0; i < parts.length; ++i) { for (let i = 0; i < parts.length; ++i) {
@ -159,14 +173,10 @@ export default class EditorModel {
newTextLength += part.text.length; newTextLength += part.text.length;
this._insertPart(insertIndex + i, part); this._insertPart(insertIndex + i, part);
} }
// put caret after new part return newTextLength;
const lastPartIndex = insertIndex + parts.length - 1;
const newPosition = new DocumentPosition(lastPartIndex, newTextLength);
this._updateCallback(newPosition);
} }
update(newValue, inputType, caret) { update(newValue, inputType, caret) {
this._updateInProgress = true;
const diff = this._diff(newValue, inputType, caret); const diff = this._diff(newValue, inputType, caret);
const position = this.positionForOffset(diff.at, caret.atNodeEnd); const position = this.positionForOffset(diff.at, caret.atNodeEnd);
let removedOffsetDecrease = 0; let removedOffsetDecrease = 0;
@ -182,13 +192,13 @@ export default class EditorModel {
this._mergeAdjacentParts(); this._mergeAdjacentParts();
const caretOffset = diff.at - removedOffsetDecrease + addedLen; const caretOffset = diff.at - removedOffsetDecrease + addedLen;
let newPosition = this.positionForOffset(caretOffset, true); let newPosition = this.positionForOffset(caretOffset, true);
this._setActivePart(newPosition, canOpenAutoComplete); const acPromise = this._setActivePart(newPosition, canOpenAutoComplete);
if (this._transformCallback) { if (this._transformCallback) {
const transformAddedLen = this._transform(newPosition, inputType, diff); const transformAddedLen = this._transform(newPosition, inputType, diff);
newPosition = this.positionForOffset(caretOffset + transformAddedLen, true); newPosition = this.positionForOffset(caretOffset + transformAddedLen, true);
} }
this._updateInProgress = false;
this._updateCallback(newPosition, inputType, diff); this._updateCallback(newPosition, inputType, diff);
return acPromise;
} }
_transform(newPosition, inputType, diff) { _transform(newPosition, inputType, diff) {
@ -214,13 +224,14 @@ export default class EditorModel {
} }
// not _autoComplete, only there if active part is autocomplete part // not _autoComplete, only there if active part is autocomplete part
if (this.autoComplete) { if (this.autoComplete) {
this.autoComplete.onPartUpdate(part, pos.offset); return this.autoComplete.onPartUpdate(part, pos.offset);
} }
} else { } else {
this._activePartIdx = null; this._activePartIdx = null;
this._autoComplete = null; this._autoComplete = null;
this._autoCompletePartIdx = null; this._autoCompletePartIdx = null;
} }
return Promise.resolve();
} }
_onAutoComplete = ({replacePart, caretOffset, close}) => { _onAutoComplete = ({replacePart, caretOffset, close}) => {
@ -395,18 +406,15 @@ export default class EditorModel {
return new Range(this, position); return new Range(this, position);
} }
// called from Range.replace //mostly internal, called from Range.replace
replaceRange(startPosition, endPosition, parts) { replaceRange(startPosition, endPosition, parts) {
// convert end position to offset, so it is independent of how the document is split into parts
// which we'll change when splitting up at the start position
const endOffset = endPosition.asOffset(this);
const newStartPartIndex = this._splitAt(startPosition); const newStartPartIndex = this._splitAt(startPosition);
const idxDiff = newStartPartIndex - startPosition.index; // convert it back to position once split at start
// if both position are in the same part, and we split it at start position, endPosition = endOffset.asPosition(this);
// the offset of the end position needs to be decreased by the offset of the start position const newEndPartIndex = this._splitAt(endPosition);
const removedOffset = startPosition.index === endPosition.index ? startPosition.offset : 0;
const adjustedEndPosition = new DocumentPosition(
endPosition.index + idxDiff,
endPosition.offset - removedOffset,
);
const newEndPartIndex = this._splitAt(adjustedEndPosition);
for (let i = newEndPartIndex - 1; i >= newStartPartIndex; --i) { for (let i = newEndPartIndex - 1; i >= newStartPartIndex; --i) {
this._removePart(i); this._removePart(i);
} }
@ -416,8 +424,18 @@ export default class EditorModel {
insertIdx += 1; insertIdx += 1;
} }
this._mergeAdjacentParts(); this._mergeAdjacentParts();
if (!this._updateInProgress) { }
this._updateCallback();
} /**
* Performs a transformation not part of an update cycle.
* Modifying the model should only happen inside a transform call if not part of an update call.
* @param {ManualTransformCallback} callback to run the transformations in
* @return {Promise} a promise when auto-complete (if applicable) is done updating
*/
transform(callback) {
const pos = callback();
const acPromise = this._setActivePart(pos, true);
this._updateCallback(pos);
return acPromise;
} }
} }

26
src/editor/offset.js Normal file
View file

@ -0,0 +1,26 @@
/*
Copyright 2019 The Matrix.org Foundation C.I.C.
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.
*/
export default class DocumentOffset {
constructor(offset, atEnd) {
this.offset = offset;
this.atEnd = atEnd;
}
asPosition(model) {
return model.positionForOffset(this.offset, this.atEnd);
}
}

View file

@ -284,6 +284,9 @@ class UserPillPart extends PillPart {
} }
setAvatar(node) { setAvatar(node) {
if (!this._member) {
return;
}
const name = this._member.name || this._member.userId; const name = this._member.name || this._member.userId;
const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this._member.userId); const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this._member.userId);
let avatarUrl = Avatar.avatarUrlForMember( let avatarUrl = Avatar.avatarUrlForMember(
@ -366,6 +369,8 @@ export class PartCreator {
constructor(room, client, autoCompleteCreator = null) { constructor(room, client, autoCompleteCreator = null) {
this._room = room; this._room = room;
this._client = client; this._client = client;
// pre-create the creator as an object even without callback so it can already be passed
// to PillCandidatePart (e.g. while deserializing) and set later on
this._autoCompleteCreator = {create: autoCompleteCreator && autoCompleteCreator(this)}; this._autoCompleteCreator = {create: autoCompleteCreator && autoCompleteCreator(this)};
} }

View file

@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import DocumentOffset from "./offset";
export default class DocumentPosition { export default class DocumentPosition {
constructor(index, offset) { constructor(index, offset) {
this._index = index; this._index = index;
@ -104,4 +106,18 @@ export default class DocumentPosition {
} }
} }
} }
asOffset(model) {
if (this.index === -1) {
return new DocumentOffset(0, true);
}
let offset = 0;
for (let i = 0; i < this.index; ++i) {
offset += model.parts[i].text.length;
}
offset += this.offset;
const lastPart = model.parts[this.index];
const atEnd = offset >= lastPart.text.length;
return new DocumentOffset(offset, atEnd);
}
} }

View file

@ -41,6 +41,12 @@ export default class Range {
return text; return text;
} }
/**
* Splits the model at the range boundaries and replaces with the given parts.
* Should be run inside a `model.transform()` callback.
* @param {Part[]} parts the parts to replace the range with
* @return {Number} the net amount of characters added, can be negative.
*/
replace(parts) { replace(parts) {
const newLength = parts.reduce((sum, part) => sum + part.text.length, 0); const newLength = parts.reduce((sum, part) => sum + part.text.length, 0);
let oldLength = 0; let oldLength = 0;

View file

@ -52,7 +52,6 @@ describe('editor/range', function() {
range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " "); range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " ");
expect(range.text).toBe("world"); expect(range.text).toBe("world");
range.replace([pc.roomPill(pillChannel)]); range.replace([pc.roomPill(pillChannel)]);
console.log({parts: JSON.stringify(model.serializeParts())});
expect(model.parts[0].type).toBe("plain"); expect(model.parts[0].type).toBe("plain");
expect(model.parts[0].text).toBe("hello "); expect(model.parts[0].text).toBe("hello ");
expect(model.parts[1].type).toBe("room-pill"); expect(model.parts[1].type).toBe("room-pill");
@ -60,7 +59,6 @@ describe('editor/range', function() {
expect(model.parts[2].type).toBe("plain"); expect(model.parts[2].type).toBe("plain");
expect(model.parts[2].text).toBe("!!!!"); expect(model.parts[2].text).toBe("!!!!");
expect(model.parts.length).toBe(3); expect(model.parts.length).toBe(3);
expect(renderer.count).toBe(1);
}); });
it('range replace across parts', function() { it('range replace across parts', function() {
const renderer = createRenderer(); const renderer = createRenderer();
@ -74,7 +72,6 @@ describe('editor/range', function() {
const range = model.startRange(model.positionForOffset(14)); // after "replace" const range = model.startRange(model.positionForOffset(14)); // after "replace"
range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " "); range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " ");
expect(range.text).toBe("replace"); expect(range.text).toBe("replace");
console.log("range.text", {text: range.text});
range.replace([pc.roomPill(pillChannel)]); range.replace([pc.roomPill(pillChannel)]);
expect(model.parts[0].type).toBe("plain"); expect(model.parts[0].type).toBe("plain");
expect(model.parts[0].text).toBe("try to "); expect(model.parts[0].text).toBe("try to ");
@ -83,6 +80,23 @@ describe('editor/range', function() {
expect(model.parts[2].type).toBe("plain"); expect(model.parts[2].type).toBe("plain");
expect(model.parts[2].text).toBe(" me"); expect(model.parts[2].text).toBe(" me");
expect(model.parts.length).toBe(3); expect(model.parts.length).toBe(3);
expect(renderer.count).toBe(1); });
// bug found while implementing tab completion
it('replace a part with an identical part with start position at end of previous part', function() {
const renderer = createRenderer();
const pc = createPartCreator();
const model = new EditorModel([
pc.plain("hello "),
pc.pillCandidate("man"),
], pc, renderer);
const range = model.startRange(model.positionForOffset(9, true)); // before "man"
range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " ");
expect(range.text).toBe("man");
range.replace([pc.pillCandidate(range.text)]);
expect(model.parts[0].type).toBe("plain");
expect(model.parts[0].text).toBe("hello ");
expect(model.parts[1].type).toBe("pill-candidate");
expect(model.parts[1].text).toBe("man");
expect(model.parts.length).toBe(2);
}); });
}); });