Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

New composer: support forcing auto complete on name by hitting tab #3349

Merged
merged 14 commits into from
Aug 28, 2019
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions res/css/views/rooms/_BasicMessageComposer.scss
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ limitations under the License.
white-space: nowrap;
}

@keyframes visualbell {
from { background-color: #faa; }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be defined in the theme?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

visualbelldarkmode

to { background-color: $primary-bg-color; }
}

&.mx_BasicMessageComposer_input_error {
animation: 0.2s visualbell;
}

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also may want Nad to weigh in on the visual flash as it currently highlight the bit where the text box actually is which is normally not visible so looks a bit odd. Not sure what the best way would be to make it look better.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, not sure. The intention was to have it exactly the same as before:

oldtabname

.mx_BasicMessageComposer_input {
white-space: pre-wrap;
word-wrap: break-word;
Expand Down
56 changes: 49 additions & 7 deletions src/components/views/rooms/BasicMessageComposer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
limitations under the License.
*/

import classNames from 'classnames';
import React from 'react';
import PropTypes from 'prop-types';
import EditorModel from '../../../editor/model';
Expand Down Expand Up @@ -75,10 +77,10 @@ export default class BasicMessageEditor extends React.Component {
this._modifiedFlag = false;
}

_replaceEmoticon = (caret, inputType, diff) => {
_replaceEmoticon = (caretPosition, inputType, diff) => {
const {model} = this.props;
const range = model.startRange(caret);
// expand range max 8 characters backwards from caret,
const range = model.startRange(caretPosition);
// expand range max 8 characters backwards from caretPosition,
// as a space to look for an emoticon
let n = 8;
range.expandBackwardsWhile((index, offset) => {
Expand All @@ -91,6 +93,7 @@ export default class BasicMessageEditor extends React.Component {
const query = emoticonMatch[1].toLowerCase().replace("-", "");
const data = EMOJIBASE.find(e => e.emoticon ? e.emoticon.toLowerCase() === query : false);
if (data) {
const {partCreator} = model;
const hasPrecedingSpace = emoticonMatch[0][0] === " ";
// we need the range to only comprise of the emoticon
// because we'll replace the whole range with an emoji,
Expand All @@ -99,7 +102,7 @@ export default class BasicMessageEditor extends React.Component {
range.moveStart(emoticonMatch.index + (hasPrecedingSpace ? 1 : 0));
// this returns the amount of added/removed characters during the replace
// so the caret position can be adjusted.
return range.replace([this.props.model.partCreator.plain(data.unicode + " ")]);
return range.replace([partCreator.plain(data.unicode + " ")]);
}
}
}
Expand Down Expand Up @@ -160,7 +163,7 @@ export default class BasicMessageEditor extends React.Component {
}

_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 ...
// maybe it's on focus and the _editorRef isn't available yet or something.
if (!this._editorRef) {
Expand Down Expand Up @@ -269,6 +272,9 @@ export default class BasicMessageEditor extends React.Component {
default:
return; // don't preventDefault on anything else
}
} else if (event.key === "Tab") {
this._tabCompleteName();
handled = true;
}
}
if (handled) {
Expand All @@ -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() {
return this._modifiedFlag;
}
Expand Down Expand Up @@ -304,7 +336,14 @@ export default class BasicMessageEditor extends React.Component {
// not really, but we could not serialize the parts, and just change the autoCompleter
partCreator.setAutoCompleteCreator(autoCompleteCreator(
() => this._autocompleteRef,
query => this.setState({query}),
query => {
return new Promise(resolve => this.setState({query}, resolve));
// if setState
// if (this.state.query === query) {
// return Promise.resolve();
// } else {
// }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

unintentionally left in?

},
));
this.historyManager = new HistoryManager(partCreator);
// initial render of model
Expand Down Expand Up @@ -345,7 +384,10 @@ export default class BasicMessageEditor extends React.Component {
/>
</div>);
}
return (<div className="mx_BasicMessageComposer">
const classes = classNames("mx_BasicMessageComposer", {
"mx_BasicMessageComposer_input_error": this.state.showVisualBell,
});
return (<div className={classes}>
{ autoComplete }
<div
className="mx_BasicMessageComposer_input"
Expand Down
19 changes: 15 additions & 4 deletions src/components/views/rooms/SendMessageComposer.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,22 +279,33 @@ export default class SendMessageComposer extends React.Component {
};

_insertMention(userId) {
const {model} = this;
const {partCreator} = model;
const member = this.props.room.getMember(userId);
const displayName = member ?
member.rawDisplayName : userId;
const userPillPart = this.model.partCreator.userPill(displayName, userId);
this.model.insertPartsAt([userPillPart], this._editorRef.getCaret());
const userPillPart = partCreator.userPill(displayName, userId);
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"
this._editorRef && this._editorRef.focus();
}

_insertQuotedMessage(event) {
const {partCreator} = this.model;
const {model} = this;
const {partCreator} = model;
const quoteParts = parseEvent(event, partCreator, { isQuotedMessage: true });
// add two newlines
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"
this._editorRef && this._editorRef.focus();
}
Expand Down
9 changes: 5 additions & 4 deletions src/editor/autocomplete.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ export default class AutocompleteWrapperModel {
});
}

close() {
this._updateCallback({close: true});
}

hasSelection() {
return this._getAutocompleterComponent().hasSelection();
}
Expand All @@ -52,9 +56,6 @@ export default class AutocompleteWrapperModel {
} else {
await acComponent.moveSelection(e.shiftKey ? -1 : +1);
}
this._updateCallback({
close: true,
});
}

onUpArrow() {
Expand All @@ -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)
this._queryPart = part;
this._queryOffset = offset;
this._updateQuery(part.text);
return this._updateQuery(part.text);
}

onComponentSelectionChange(completion) {
Expand Down
70 changes: 44 additions & 26 deletions src/editor/model.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ import Range from "./range";
* This is used to adjust the caret position.
*/

/**
* @callback ManualTransformCallback
* @return the caret position
*/

export default class EditorModel {
constructor(parts, partCreator, updateCallback = null) {
this._parts = parts;
Expand All @@ -44,7 +49,6 @@ export default class EditorModel {
this._autoCompletePartIdx = null;
this._transformCallback = null;
this.setUpdateCallback(updateCallback);
this._updateInProgress = false;
}

/**
Expand Down Expand Up @@ -90,10 +94,14 @@ export default class EditorModel {

_removePart(index) {
this._parts.splice(index, 1);
if (this._activePartIdx >= index) {
if (index === this._activePartIdx) {
this._activePartIdx = null;
} else if (this._activePartIdx > index) {
--this._activePartIdx;
}
if (this._autoCompletePartIdx >= index) {
if (index === this._autoCompletePartIdx) {
this._autoCompletePartIdx = null;
} else if (this._autoCompletePartIdx > index) {
--this._autoCompletePartIdx;
}
}
Expand Down Expand Up @@ -150,23 +158,25 @@ export default class EditorModel {
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);
let newTextLength = 0;
for (let i = 0; i < parts.length; ++i) {
const part = parts[i];
newTextLength += part.text.length;
this._insertPart(insertIndex + i, part);
}
// put caret after new part
const lastPartIndex = insertIndex + parts.length - 1;
const newPosition = new DocumentPosition(lastPartIndex, newTextLength);
this._updateCallback(newPosition);
return newTextLength;
}

update(newValue, inputType, caret) {
this._updateInProgress = true;
const diff = this._diff(newValue, inputType, caret);
const position = this.positionForOffset(diff.at, caret.atNodeEnd);
let removedOffsetDecrease = 0;
Expand All @@ -182,13 +192,13 @@ export default class EditorModel {
this._mergeAdjacentParts();
const caretOffset = diff.at - removedOffsetDecrease + addedLen;
let newPosition = this.positionForOffset(caretOffset, true);
this._setActivePart(newPosition, canOpenAutoComplete);
const acPromise = this._setActivePart(newPosition, canOpenAutoComplete);
if (this._transformCallback) {
const transformAddedLen = this._transform(newPosition, inputType, diff);
newPosition = this.positionForOffset(caretOffset + transformAddedLen, true);
}
this._updateInProgress = false;
this._updateCallback(newPosition, inputType, diff);
return acPromise;
}

_transform(newPosition, inputType, diff) {
Expand All @@ -214,13 +224,14 @@ export default class EditorModel {
}
// not _autoComplete, only there if active part is autocomplete part
if (this.autoComplete) {
this.autoComplete.onPartUpdate(part, pos.offset);
return this.autoComplete.onPartUpdate(part, pos.offset);
}
} else {
this._activePartIdx = null;
this._autoComplete = null;
this._autoCompletePartIdx = null;
}
return Promise.resolve();
}

_onAutoComplete = ({replacePart, caretOffset, close}) => {
Expand Down Expand Up @@ -395,18 +406,15 @@ export default class EditorModel {
return new Range(this, position);
}

// called from Range.replace
//mostly internal, called from Range.replace
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 idxDiff = newStartPartIndex - startPosition.index;
// if both position are in the same part, and we split it at start position,
// the offset of the end position needs to be decreased by the offset of the start position
const removedOffset = startPosition.index === endPosition.index ? startPosition.offset : 0;
const adjustedEndPosition = new DocumentPosition(
endPosition.index + idxDiff,
endPosition.offset - removedOffset,
);
const newEndPartIndex = this._splitAt(adjustedEndPosition);
// convert it back to position once split at start
endPosition = endOffset.asPosition(this);
const newEndPartIndex = this._splitAt(endPosition);
for (let i = newEndPartIndex - 1; i >= newStartPartIndex; --i) {
this._removePart(i);
}
Expand All @@ -416,8 +424,18 @@ export default class EditorModel {
insertIdx += 1;
}
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 changes: 26 additions & 0 deletions src/editor/offset.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
5 changes: 5 additions & 0 deletions src/editor/parts.js
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,9 @@ class UserPillPart extends PillPart {
}

setAvatar(node) {
if (!this._member) {
return;
}
const name = this._member.name || this._member.userId;
const defaultAvatarUrl = Avatar.defaultAvatarUrlForString(this._member.userId);
let avatarUrl = Avatar.avatarUrlForMember(
Expand Down Expand Up @@ -366,6 +369,8 @@ export class PartCreator {
constructor(room, client, autoCompleteCreator = null) {
this._room = room;
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)};
}

Expand Down
Loading