From c135cd60d2e6372100e919d85fd51aa08e898f32 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 7 Aug 2019 17:44:49 +0200 Subject: [PATCH] restore insert mention for this, we need to store the last caret in the editor, to know where to insert the user pill. Because clicking on a member blurs the editor, and the selection is moved away from the editor. For this reason, we keep as cache of the last caretOffset object, invalidated by a selection with different values. The selection needs to be cloned because apparently the browser mutates the object instead of returning a new one. --- .../views/rooms/BasicMessageComposer.js | 93 +++++++++++++++---- .../views/rooms/SendMessageComposer.js | 9 ++ src/editor/model.js | 26 ++++++ 3 files changed, 111 insertions(+), 17 deletions(-) diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js index 4a17ec6066..62e136000f 100644 --- a/src/components/views/rooms/BasicMessageComposer.js +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -28,6 +28,28 @@ import {Room} from 'matrix-js-sdk'; const IS_MAC = navigator.platform.indexOf("Mac") !== -1; +function cloneSelection(selection) { + return { + anchorNode: selection.anchorNode, + anchorOffset: selection.anchorOffset, + focusNode: selection.focusNode, + focusOffset: selection.focusOffset, + isCollapsed: selection.isCollapsed, + rangeCount: selection.rangeCount, + type: selection.type, + }; +} + +function selectionEquals(a: Selection, b: Selection): boolean { + return a.anchorNode === b.anchorNode && + a.anchorOffset === b.anchorOffset && + a.focusNode === b.focusNode && + a.focusOffset === b.focusOffset && + a.isCollapsed === b.isCollapsed && + a.rangeCount === b.rangeCount && + a.type === b.type; +} + export default class BasicMessageEditor extends React.Component { static propTypes = { model: PropTypes.instanceOf(EditorModel).isRequired, @@ -74,6 +96,7 @@ export default class BasicMessageEditor extends React.Component { this._modifiedFlag = true; const sel = document.getSelection(); const {caret, text} = getCaretOffsetAndText(this._editorRef, sel); + this._setLastCaret(caret, text, sel); this.props.model.update(text, event.inputType, caret); } @@ -85,14 +108,59 @@ export default class BasicMessageEditor extends React.Component { this.props.model.update(newText, inputType, caret); } - _isCaretAtStart() { - const {caret} = getCaretOffsetAndText(this._editorRef, document.getSelection()); - return caret.offset === 0; + // this is used later to see if we need to recalculate the caret + // on selectionchange. If it is just a consequence of typing + // we don't need to. But if the user is navigating the caret without input + // we need to recalculate it, to be able to know where to insert content after + // losing focus + _setLastCaret(caret, text, selection) { + this._lastSelection = cloneSelection(selection); + this._lastCaret = caret; + this._lastTextLength = text.length; } - _isCaretAtEnd() { - const {caret, text} = getCaretOffsetAndText(this._editorRef, document.getSelection()); - return caret.offset === text.length; + _refreshLastCaretIfNeeded() { + // TODO: 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) { + return; + } + const selection = document.getSelection(); + if (!this._lastSelection || !selectionEquals(this._lastSelection, selection)) { + this._lastSelection = cloneSelection(selection); + const {caret, text} = getCaretOffsetAndText(this._editorRef, selection); + this._lastCaret = caret; + this._lastTextLength = text.length; + } + return this._lastCaret; + } + + getCaret() { + return this._lastCaret; + } + + isCaretAtStart() { + return this.getCaret().offset === 0; + } + + isCaretAtEnd() { + return this.getCaret().offset === this._lastTextLength; + } + + _onBlur = () => { + document.removeEventListener("selectionchange", this._onSelectionChange); + } + + _onFocus = () => { + document.addEventListener("selectionchange", this._onSelectionChange); + // force to recalculate + this._lastSelection = null; + this._refreshLastCaretIfNeeded(); + } + + _onSelectionChange = () => { + this._refreshLastCaretIfNeeded(); } _onKeyDown = (event) => { @@ -202,17 +270,6 @@ export default class BasicMessageEditor extends React.Component { return caretPosition; } - - isCaretAtStart() { - const {caret} = getCaretOffsetAndText(this._editorRef, document.getSelection()); - return caret.offset === 0; - } - - isCaretAtEnd() { - const {caret, text} = getCaretOffsetAndText(this._editorRef, document.getSelection()); - return caret.offset === text.length; - } - render() { let autoComplete; if (this.state.autoComplete) { @@ -235,6 +292,8 @@ export default class BasicMessageEditor extends React.Component { className="mx_BasicMessageComposer_input" contentEditable="true" tabIndex="1" + onBlur={this._onBlur} + onFocus={this._onFocus} onKeyDown={this._onKeyDown} ref={ref => this._editorRef = ref} aria-label={this.props.label} diff --git a/src/components/views/rooms/SendMessageComposer.js b/src/components/views/rooms/SendMessageComposer.js index c45ecaa9f3..fdc5fdd9e2 100644 --- a/src/components/views/rooms/SendMessageComposer.js +++ b/src/components/views/rooms/SendMessageComposer.js @@ -97,6 +97,15 @@ export default class SendMessageComposer extends React.Component { case 'focus_composer': this._editorRef.focus(); break; + case 'insert_mention': { + const userId = payload.user_id; + const member = this.props.room.getMember(userId); + const displayName = member ? + member.rawDisplayName : payload.user_id; + const userPillPart = this.model.partCreator.userPill(displayName, userId); + this.model.insertPartAt(userPillPart, this._editorRef.getCaret()); + break; + } } }; diff --git a/src/editor/model.js b/src/editor/model.js index 64986cdaf2..3a8c02da1b 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -108,6 +108,15 @@ export default class EditorModel { this._updateCallback(caret, inputType); } + insertPartAt(part, caret) { + const position = this.positionForOffset(caret.offset, caret.atNodeEnd); + const insertIndex = this._splitAt(position); + this._insertPart(insertIndex, part); + // want to put caret after new part? + const newPosition = new DocumentPosition(insertIndex, part.text.length); + this._updateCallback(newPosition); + } + update(newValue, inputType, caret) { const diff = this._diff(newValue, inputType, caret); const position = this.positionForOffset(diff.at, caret.atNodeEnd); @@ -232,6 +241,23 @@ export default class EditorModel { } return removedOffsetDecrease; } + // return part index where insertion will insert between at offset + _splitAt(pos) { + if (pos.index === -1) { + return 0; + } + if (pos.offset === 0) { + return pos.index; + } + const part = this._parts[pos.index]; + if (pos.offset >= part.text.length) { + return pos.index + 1; + } + + const secondPart = part.split(pos.offset); + this._insertPart(pos.index + 1, secondPart); + return pos.index + 1; + } /** * inserts `str` into the model at `pos`.