Merge pull request #3342 from matrix-org/bwindels/cider-replace-emoticons

Auto-replace emoticons with emojis in new composer
This commit is contained in:
Bruno Windels 2019-08-27 14:40:53 +00:00 committed by GitHub
commit e0b99b5cc8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 444 additions and 13 deletions

View file

@ -25,6 +25,11 @@ import {autoCompleteCreator} from '../../../editor/parts';
import {renderModel} from '../../../editor/render';
import {Room} from 'matrix-js-sdk';
import TypingStore from "../../../stores/TypingStore";
import EMOJIBASE from 'emojibase-data/en/compact.json';
import SettingsStore from "../../../settings/SettingsStore";
import EMOTICON_REGEX from 'emojibase-regex/emoticon';
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
const IS_MAC = navigator.platform.indexOf("Mac") !== -1;
@ -70,6 +75,35 @@ export default class BasicMessageEditor extends React.Component {
this._modifiedFlag = false;
}
_replaceEmoticon = (caret, inputType, diff) => {
const {model} = this.props;
const range = model.startRange(caret);
// expand range max 8 characters backwards from caret,
// as a space to look for an emoticon
let n = 8;
range.expandBackwardsWhile((index, offset) => {
const part = model.parts[index];
n -= 1;
return n >= 0 && (part.type === "plain" || part.type === "pill-candidate");
});
const emoticonMatch = REGEX_EMOTICON_WHITESPACE.exec(range.text);
if (emoticonMatch) {
const query = emoticonMatch[1].toLowerCase().replace("-", "");
const data = EMOJIBASE.find(e => e.emoticon ? e.emoticon.toLowerCase() === query : false);
if (data) {
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,
// so move the start forward to the start of the emoticon.
// Take + 1 because index is reported without the possible preceding space.
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 + " ")]);
}
}
}
_updateEditorState = (caret, inputType, diff) => {
renderModel(this._editorRef, this.props.model);
if (caret) {
@ -262,6 +296,9 @@ export default class BasicMessageEditor extends React.Component {
componentDidMount() {
const model = this.props.model;
model.setUpdateCallback(this._updateEditorState);
if (SettingsStore.getValue('MessageComposerInput.autoReplaceEmoji')) {
model.setTransformCallback(this._replaceEmoticon);
}
const partCreator = model.partCreator;
// TODO: does this allow us to get rid of EditorStateTransfer?
// not really, but we could not serialize the parts, and just change the autoCompleter

View file

@ -16,6 +16,24 @@ limitations under the License.
*/
import {diffAtCaret, diffDeletion} from "./diff";
import DocumentPosition from "./position";
import Range from "./range";
/**
* @callback ModelCallback
* @param {DocumentPosition?} caretPosition the position where the caret should be position
* @param {string?} inputType the inputType of the DOM input event
* @param {object?} diff an object with `removed` and `added` strings
*/
/**
* @callback TransformCallback
* @param {DocumentPosition?} caretPosition the position where the caret should be position
* @param {string?} inputType the inputType of the DOM input event
* @param {object?} diff an object with `removed` and `added` strings
* @return {Number?} addedLen how many characters were added/removed (-) before the caret during the transformation step.
* This is used to adjust the caret position.
*/
export default class EditorModel {
constructor(parts, partCreator, updateCallback = null) {
@ -24,9 +42,26 @@ export default class EditorModel {
this._activePartIdx = null;
this._autoComplete = null;
this._autoCompletePartIdx = null;
this._transformCallback = null;
this.setUpdateCallback(updateCallback);
this._updateInProgress = false;
}
/**
* Set a callback for the transformation step.
* While processing an update, right before calling the update callback,
* a transform callback can be called, which serves to do modifications
* on the model that can span multiple parts. Also see `startRange()`.
* @param {TransformCallback} transformCallback
*/
setTransformCallback(transformCallback) {
this._transformCallback = transformCallback;
}
/**
* Set a callback for rerendering the model after it has been updated.
* @param {ModelCallback} updateCallback
*/
setUpdateCallback(updateCallback) {
this._updateCallback = updateCallback;
}
@ -131,6 +166,7 @@ export default class EditorModel {
}
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;
@ -145,11 +181,21 @@ export default class EditorModel {
}
this._mergeAdjacentParts();
const caretOffset = diff.at - removedOffsetDecrease + addedLen;
const newPosition = this.positionForOffset(caretOffset, true);
let newPosition = this.positionForOffset(caretOffset, true);
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);
}
_transform(newPosition, inputType, diff) {
const result = this._transformCallback(newPosition, inputType, diff);
return Number.isFinite(result) ? result : 0;
}
_setActivePart(pos, canOpenAutoComplete) {
const {index} = pos;
const part = this._parts[index];
@ -197,7 +243,7 @@ export default class EditorModel {
this._updateCallback(pos);
}
_mergeAdjacentParts(docPos) {
_mergeAdjacentParts() {
let prevPart;
for (let i = 0; i < this._parts.length; ++i) {
let part = this._parts[i];
@ -339,19 +385,39 @@ export default class EditorModel {
return new DocumentPosition(index, totalOffset - currentOffset);
}
}
class DocumentPosition {
constructor(index, offset) {
this._index = index;
this._offset = offset;
/**
* Starts a range, which can span across multiple parts, to find and replace text.
* @param {DocumentPosition} position where to start the range
* @return {Range}
*/
startRange(position) {
return new Range(this, position);
}
get index() {
return this._index;
}
get offset() {
return this._offset;
// called from Range.replace
replaceRange(startPosition, endPosition, parts) {
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);
for (let i = newEndPartIndex - 1; i >= newStartPartIndex; --i) {
this._removePart(i);
}
let insertIdx = newStartPartIndex;
for (const part of parts) {
this._insertPart(insertIdx, part);
insertIdx += 1;
}
this._mergeAdjacentParts();
if (!this._updateInProgress) {
this._updateCallback();
}
}
}

107
src/editor/position.js Normal file
View file

@ -0,0 +1,107 @@
/*
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 DocumentPosition {
constructor(index, offset) {
this._index = index;
this._offset = offset;
}
get index() {
return this._index;
}
get offset() {
return this._offset;
}
compare(otherPos) {
if (this._index === otherPos._index) {
return this._offset - otherPos._offset;
} else {
return this._index - otherPos._index;
}
}
iteratePartsBetween(other, model, callback) {
if (this.index === -1 || other.index === -1) {
return;
}
const [startPos, endPos] = this.compare(other) < 0 ? [this, other] : [other, this];
if (startPos.index === endPos.index) {
callback(model.parts[this.index], startPos.offset, endPos.offset);
} else {
const firstPart = model.parts[startPos.index];
callback(firstPart, startPos.offset, firstPart.text.length);
for (let i = startPos.index + 1; i < endPos.index; ++i) {
const part = model.parts[i];
callback(part, 0, part.text.length);
}
const lastPart = model.parts[endPos.index];
callback(lastPart, 0, endPos.offset);
}
}
forwardsWhile(model, predicate) {
if (this.index === -1) {
return this;
}
let {index, offset} = this;
const {parts} = model;
while (index < parts.length) {
const part = parts[index];
while (offset < part.text.length) {
if (!predicate(index, offset, part)) {
return new DocumentPosition(index, offset);
}
offset += 1;
}
// end reached
if (index === (parts.length - 1)) {
return new DocumentPosition(index, offset);
} else {
index += 1;
offset = 0;
}
}
}
backwardsWhile(model, predicate) {
if (this.index === -1) {
return this;
}
let {index, offset} = this;
const parts = model.parts;
while (index >= 0) {
const part = parts[index];
while (offset > 0) {
if (!predicate(index, offset - 1, part)) {
return new DocumentPosition(index, offset);
}
offset -= 1;
}
// start reached
if (index === 0) {
return new DocumentPosition(index, offset);
} else {
index -= 1;
offset = parts[index].text.length;
}
}
}
}

53
src/editor/range.js Normal file
View file

@ -0,0 +1,53 @@
/*
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 Range {
constructor(model, startPosition, endPosition = startPosition) {
this._model = model;
this._start = startPosition;
this._end = endPosition;
}
moveStart(delta) {
this._start = this._start.forwardsWhile(this._model, () => {
delta -= 1;
return delta >= 0;
});
}
expandBackwardsWhile(predicate) {
this._start = this._start.backwardsWhile(this._model, predicate);
}
get text() {
let text = "";
this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => {
const t = part.text.substring(startIdx, endIdx);
text = text + t;
});
return text;
}
replace(parts) {
const newLength = parts.reduce((sum, part) => sum + part.text.length, 0);
let oldLength = 0;
this._start.iteratePartsBetween(this._end, this._model, (part, startIdx, endIdx) => {
oldLength += endIdx - startIdx;
});
this._model.replaceRange(this._start, this._end, parts);
return newLength - oldLength;
}
}

View file

@ -0,0 +1,80 @@
/*
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.
*/
import expect from 'expect';
import EditorModel from "../../src/editor/model";
import {createPartCreator} from "./mock";
function createRenderer() {
const render = (c) => {
render.caret = c;
render.count += 1;
};
render.count = 0;
render.caret = null;
return render;
}
describe('editor/position', function() {
it('move first position backward in empty model', function() {
const model = new EditorModel([], createPartCreator(), createRenderer());
const pos = model.positionForOffset(0, true);
const pos2 = pos.backwardsWhile(model, () => true);
expect(pos).toBe(pos2);
});
it('move first position forwards in empty model', function() {
const model = new EditorModel([], createPartCreator(), createRenderer());
const pos = model.positionForOffset(0, true);
const pos2 = pos.forwardsWhile(() => true);
expect(pos).toBe(pos2);
});
it('move forwards within one part', function() {
const pc = createPartCreator();
const model = new EditorModel([pc.plain("hello")], pc, createRenderer());
const pos = model.positionForOffset(1);
let n = 3;
const pos2 = pos.forwardsWhile(model, () => { n -= 1; return n >= 0; });
expect(pos2.index).toBe(0);
expect(pos2.offset).toBe(4);
});
it('move forwards crossing to other part', function() {
const pc = createPartCreator();
const model = new EditorModel([pc.plain("hello"), pc.plain(" world")], pc, createRenderer());
const pos = model.positionForOffset(4);
let n = 3;
const pos2 = pos.forwardsWhile(model, () => { n -= 1; return n >= 0; });
expect(pos2.index).toBe(1);
expect(pos2.offset).toBe(2);
});
it('move backwards within one part', function() {
const pc = createPartCreator();
const model = new EditorModel([pc.plain("hello")], pc, createRenderer());
const pos = model.positionForOffset(4);
let n = 3;
const pos2 = pos.backwardsWhile(model, () => { n -= 1; return n >= 0; });
expect(pos2.index).toBe(0);
expect(pos2.offset).toBe(1);
});
it('move backwards crossing to other part', function() {
const pc = createPartCreator();
const model = new EditorModel([pc.plain("hello"), pc.plain(" world")], pc, createRenderer());
const pos = model.positionForOffset(7);
let n = 3;
const pos2 = pos.backwardsWhile(model, () => { n -= 1; return n >= 0; });
expect(pos2.index).toBe(0);
expect(pos2.offset).toBe(4);
});
});

88
test/editor/range-test.js Normal file
View file

@ -0,0 +1,88 @@
/*
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.
*/
import expect from 'expect';
import EditorModel from "../../src/editor/model";
import {createPartCreator} from "./mock";
function createRenderer() {
const render = (c) => {
render.caret = c;
render.count += 1;
};
render.count = 0;
render.caret = null;
return render;
}
const pillChannel = "#riot-dev:matrix.org";
describe('editor/range', function() {
it('range on empty model', function() {
const renderer = createRenderer();
const pc = createPartCreator();
const model = new EditorModel([], pc, renderer);
const range = model.startRange(model.positionForOffset(0, true)); // after "world"
let called = false;
range.expandBackwardsWhile(chr => {
called = true;
return true;
});
expect(called).toBe(false);
expect(range.text).toBe("");
});
it('range replace within a part', function() {
const renderer = createRenderer();
const pc = createPartCreator();
const model = new EditorModel([pc.plain("hello world!!!!")], pc, renderer);
const range = model.startRange(model.positionForOffset(11)); // after "world"
range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " ");
expect(range.text).toBe("world");
range.replace([pc.roomPill(pillChannel)]);
console.log({parts: JSON.stringify(model.serializeParts())});
expect(model.parts[0].type).toBe("plain");
expect(model.parts[0].text).toBe("hello ");
expect(model.parts[1].type).toBe("room-pill");
expect(model.parts[1].text).toBe(pillChannel);
expect(model.parts[2].type).toBe("plain");
expect(model.parts[2].text).toBe("!!!!");
expect(model.parts.length).toBe(3);
expect(renderer.count).toBe(1);
});
it('range replace across parts', function() {
const renderer = createRenderer();
const pc = createPartCreator();
const model = new EditorModel([
pc.plain("try to re"),
pc.plain("pla"),
pc.plain("ce "),
pc.plain("me"),
], pc, renderer);
const range = model.startRange(model.positionForOffset(14)); // after "replace"
range.expandBackwardsWhile((index, offset) => model.parts[index].text[offset] !== " ");
expect(range.text).toBe("replace");
console.log("range.text", {text: range.text});
range.replace([pc.roomPill(pillChannel)]);
expect(model.parts[0].type).toBe("plain");
expect(model.parts[0].text).toBe("try to ");
expect(model.parts[1].type).toBe("room-pill");
expect(model.parts[1].text).toBe(pillChannel);
expect(model.parts[2].type).toBe("plain");
expect(model.parts[2].text).toBe(" me");
expect(model.parts.length).toBe(3);
expect(renderer.count).toBe(1);
});
});