diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index e4179d9c3b..662167b714 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -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 diff --git a/src/editor/model.js b/src/editor/model.js index 2f1e5218d8..9d129afa69 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -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(); + } } } diff --git a/src/editor/position.js b/src/editor/position.js new file mode 100644 index 0000000000..5dcb31fe65 --- /dev/null +++ b/src/editor/position.js @@ -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; + } + } + } +} diff --git a/src/editor/range.js b/src/editor/range.js new file mode 100644 index 0000000000..e2ecc5d12b --- /dev/null +++ b/src/editor/range.js @@ -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; + } +} diff --git a/test/editor/position-test.js b/test/editor/position-test.js new file mode 100644 index 0000000000..7ac4284c60 --- /dev/null +++ b/test/editor/position-test.js @@ -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); + }); +}); diff --git a/test/editor/range-test.js b/test/editor/range-test.js new file mode 100644 index 0000000000..5a95da952d --- /dev/null +++ b/test/editor/range-test.js @@ -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); + }); +});