From 299cf8542c52e843ed7c8f5b3858e35a7c44a8cd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 5 Aug 2019 15:27:40 +0200 Subject: [PATCH 1/2] Split MessageEditor in edit-specifics & reusable part for main composer --- src/components/views/messages/TextualBody.js | 4 +- .../views/rooms/BasicMessageComposer.js | 232 ++++++++++++++++++ .../EditMessageComposer.js} | 180 ++------------ src/editor/model.js | 8 + src/editor/parts.js | 10 +- test/editor/mock.js | 2 +- 6 files changed, 273 insertions(+), 163 deletions(-) create mode 100644 src/components/views/rooms/BasicMessageComposer.js rename src/components/views/{elements/MessageEditor.js => rooms/EditMessageComposer.js} (57%) diff --git a/src/components/views/messages/TextualBody.js b/src/components/views/messages/TextualBody.js index 8f95c9cf5c..bfa4860160 100644 --- a/src/components/views/messages/TextualBody.js +++ b/src/components/views/messages/TextualBody.js @@ -383,8 +383,8 @@ module.exports = React.createClass({ render: function() { if (this.props.editState) { - const MessageEditor = sdk.getComponent('elements.MessageEditor'); - return ; + const EditMessageComposer = sdk.getComponent('rooms.EditMessageComposer'); + return ; } const mxEvent = this.props.mxEvent; const content = mxEvent.getContent(); diff --git a/src/components/views/rooms/BasicMessageComposer.js b/src/components/views/rooms/BasicMessageComposer.js new file mode 100644 index 0000000000..76de9a6794 --- /dev/null +++ b/src/components/views/rooms/BasicMessageComposer.js @@ -0,0 +1,232 @@ +/* +Copyright 2019 New Vector Ltd +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 React from 'react'; +import {_t} from '../../../languageHandler'; +import PropTypes from 'prop-types'; +import dis from '../../../dispatcher'; +import EditorModel from '../../../editor/model'; +import HistoryManager from '../../../editor/history'; +import {setCaretPosition} from '../../../editor/caret'; +import {getCaretOffsetAndText} from '../../../editor/dom'; +import Autocomplete from '../rooms/Autocomplete'; +import {autoCompleteCreator} from '../../../editor/parts'; +import {renderModel} from '../../../editor/render'; +import {Room} from 'matrix-js-sdk'; + +const IS_MAC = navigator.platform.indexOf("Mac") !== -1; + +export default class BasicMessageEditor extends React.Component { + static propTypes = { + model: PropTypes.instanceOf(EditorModel).isRequired, + room: PropTypes.instanceOf(Room).isRequired, + }; + + constructor(props, context) { + super(props, context); + this.state = { + autoComplete: null, + }; + this._editorRef = null; + this._autocompleteRef = null; + this._modifiedFlag = false; + } + + _updateEditorState = (caret, inputType, diff) => { + renderModel(this._editorRef, this.props.model); + if (caret) { + try { + setCaretPosition(this._editorRef, this.props.model, caret); + } catch (err) { + console.error(err); + } + } + this.setState({autoComplete: this.props.model.autoComplete}); + this.historyManager.tryPush(this.props.model, caret, inputType, diff); + } + + _onInput = (event) => { + this._modifiedFlag = true; + const sel = document.getSelection(); + const {caret, text} = getCaretOffsetAndText(this._editorRef, sel); + this.props.model.update(text, event.inputType, caret); + } + + _insertText(textToInsert, inputType = "insertText") { + const sel = document.getSelection(); + const {caret, text} = getCaretOffsetAndText(this._editorRef, sel); + const newText = text.substr(0, caret.offset) + textToInsert + text.substr(caret.offset); + caret.offset += textToInsert.length; + this.props.model.update(newText, inputType, caret); + } + + _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; + } + + _onKeyDown = (event) => { + const model = this.props.model; + const modKey = IS_MAC ? event.metaKey : event.ctrlKey; + let handled = false; + // undo + if (modKey && event.key === "z") { + if (this.historyManager.canUndo()) { + const {parts, caret} = this.historyManager.undo(this.props.model); + // pass matching inputType so historyManager doesn't push echo + // when invoked from rerender callback. + model.reset(parts, caret, "historyUndo"); + } + handled = true; + // redo + } else if (modKey && event.key === "y") { + if (this.historyManager.canRedo()) { + const {parts, caret} = this.historyManager.redo(); + // pass matching inputType so historyManager doesn't push echo + // when invoked from rerender callback. + model.reset(parts, caret, "historyRedo"); + } + handled = true; + // insert newline on Shift+Enter + } else if (event.shiftKey && event.key === "Enter") { + this._insertText("\n"); + handled = true; + // autocomplete or enter to send below shouldn't have any modifier keys pressed. + } else if (!(event.metaKey || event.altKey || event.shiftKey)) { + if (model.autoComplete) { + const autoComplete = model.autoComplete; + switch (event.key) { + case "Enter": + autoComplete.onEnter(event); break; + case "ArrowUp": + autoComplete.onUpArrow(event); break; + case "ArrowDown": + autoComplete.onDownArrow(event); break; + case "Tab": + autoComplete.onTab(event); break; + case "Escape": + autoComplete.onEscape(event); break; + default: + return; // don't preventDefault on anything else + } + handled = true; + } + } + if (handled) { + event.preventDefault(); + event.stopPropagation(); + } + } + + _cancelEdit = () => { + dis.dispatch({action: "edit_event", event: null}); + dis.dispatch({action: 'focus_composer'}); + } + + isModified() { + return this._modifiedFlag; + } + + _onAutoCompleteConfirm = (completion) => { + this.props.model.autoComplete.onComponentConfirm(completion); + } + + _onAutoCompleteSelectionChange = (completion) => { + this.props.model.autoComplete.onComponentSelectionChange(completion); + } + + componentWillUnmount() { + this._editorRef.removeEventListener("input", this._onInput, true); + } + + componentDidMount() { + const model = this.props.model; + model.setUpdateCallback(this._updateEditorState); + 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 + partCreator.setAutoCompleteCreator(autoCompleteCreator( + () => this._autocompleteRef, + query => this.setState({query}), + )); + this.historyManager = new HistoryManager(partCreator); + // initial render of model + this._updateEditorState(this._getInitialCaretPosition()); + // attach input listener by hand so React doesn't proxy the events, + // as the proxied event doesn't support inputType, which we need. + this._editorRef.addEventListener("input", this._onInput, true); + this._editorRef.focus(); + } + + _getInitialCaretPosition() { + let caretPosition; + if (this.props.initialCaret) { + // if restoring state from a previous editor, + // restore caret position from the state + const caret = this.props.initialCaret; + caretPosition = this.props.model.positionForOffset(caret.offset, caret.atNodeEnd); + } else { + // otherwise, set it at the end + caretPosition = this.props.model.getPositionAtEnd(); + } + 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) { + const query = this.state.query; + const queryLen = query.length; + autoComplete =
+ this._autocompleteRef = ref} + query={query} + onConfirm={this._onAutoCompleteConfirm} + onSelectionChange={this._onAutoCompleteSelectionChange} + selection={{beginning: true, end: queryLen, start: queryLen}} + room={this.props.room} + /> +
; + } + return
+ { autoComplete } +
this._editorRef = ref} + aria-label={_t("Edit message")} + >
+
; + } +} diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/rooms/EditMessageComposer.js similarity index 57% rename from src/components/views/elements/MessageEditor.js rename to src/components/views/rooms/EditMessageComposer.js index 3d113d5223..3ba14d9369 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/rooms/EditMessageComposer.js @@ -20,21 +20,16 @@ import {_t} from '../../../languageHandler'; import PropTypes from 'prop-types'; import dis from '../../../dispatcher'; import EditorModel from '../../../editor/model'; -import HistoryManager from '../../../editor/history'; -import {setCaretPosition} from '../../../editor/caret'; import {getCaretOffsetAndText} from '../../../editor/dom'; import {htmlSerializeIfNeeded, textSerialize} from '../../../editor/serialize'; import {findEditableEvent} from '../../../utils/EventUtils'; import {parseEvent} from '../../../editor/deserialize'; -import Autocomplete from '../rooms/Autocomplete'; -import {PartCreator, autoCompleteCreator} from '../../../editor/parts'; -import {renderModel} from '../../../editor/render'; +import {PartCreator} from '../../../editor/parts'; import EditorStateTransfer from '../../../utils/EditorStateTransfer'; import {MatrixClient} from 'matrix-js-sdk'; import classNames from 'classnames'; import {EventStatus} from 'matrix-js-sdk'; - -const IS_MAC = navigator.platform.indexOf("Mac") !== -1; +import BasicMessageComposer from "./BasicMessageComposer"; function _isReply(mxEvent) { const relatesTo = mxEvent.getContent()["m.relates_to"]; @@ -110,7 +105,7 @@ function createEditContent(model, editedEvent) { }, contentBody); } -export default class MessageEditor extends React.Component { +export default class EditMessageComposer extends React.Component { static propTypes = { // the message event being edited editState: PropTypes.instanceOf(EditorStateTransfer).isRequired, @@ -122,115 +117,29 @@ export default class MessageEditor extends React.Component { constructor(props, context) { super(props, context); - const room = this._getRoom(); this.model = null; - this.state = { - autoComplete: null, - room, - }; this._editorRef = null; - this._autocompleteRef = null; - this._modifiedFlag = false; } + _setEditorRef = ref => { + this._editorRef = ref; + }; + _getRoom() { return this.context.matrixClient.getRoom(this.props.editState.getEvent().getRoomId()); } - _updateEditorState = (caret, inputType, diff) => { - renderModel(this._editorRef, this.model); - if (caret) { - try { - setCaretPosition(this._editorRef, this.model, caret); - } catch (err) { - console.error(err); - } - } - this.setState({autoComplete: this.model.autoComplete}); - this.historyManager.tryPush(this.model, caret, inputType, diff); - } - - _onInput = (event) => { - this._modifiedFlag = true; - const sel = document.getSelection(); - const {caret, text} = getCaretOffsetAndText(this._editorRef, sel); - this.model.update(text, event.inputType, caret); - } - - _insertText(textToInsert, inputType = "insertText") { - const sel = document.getSelection(); - const {caret, text} = getCaretOffsetAndText(this._editorRef, sel); - const newText = text.substr(0, caret.offset) + textToInsert + text.substr(caret.offset); - caret.offset += textToInsert.length; - this.model.update(newText, inputType, caret); - } - - _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; - } - _onKeyDown = (event) => { - const modKey = IS_MAC ? event.metaKey : event.ctrlKey; - // undo - if (modKey && event.key === "z") { - if (this.historyManager.canUndo()) { - const {parts, caret} = this.historyManager.undo(this.model); - // pass matching inputType so historyManager doesn't push echo - // when invoked from rerender callback. - this.model.reset(parts, caret, "historyUndo"); - } - event.preventDefault(); - } - // redo - if (modKey && event.key === "y") { - if (this.historyManager.canRedo()) { - const {parts, caret} = this.historyManager.redo(); - // pass matching inputType so historyManager doesn't push echo - // when invoked from rerender callback. - this.model.reset(parts, caret, "historyRedo"); - } - event.preventDefault(); - } - // insert newline on Shift+Enter - if (event.shiftKey && event.key === "Enter") { - event.preventDefault(); // just in case the browser does support this - this._insertText("\n"); - return; - } - // autocomplete or enter to send below shouldn't have any modifier keys pressed. if (event.metaKey || event.altKey || event.shiftKey) { return; } - if (this.model.autoComplete) { - const autoComplete = this.model.autoComplete; - switch (event.key) { - case "Enter": - autoComplete.onEnter(event); break; - case "ArrowUp": - autoComplete.onUpArrow(event); break; - case "ArrowDown": - autoComplete.onDownArrow(event); break; - case "Tab": - autoComplete.onTab(event); break; - case "Escape": - autoComplete.onEscape(event); break; - default: - return; // don't preventDefault on anything else - } - event.preventDefault(); - } else if (event.key === "Enter") { + if (event.key === "Enter") { this._sendEdit(); event.preventDefault(); } else if (event.key === "Escape") { this._cancelEdit(); } else if (event.key === "ArrowUp") { - if (this._modifiedFlag || !this._isCaretAtStart()) { + if (this._editorRef.isModified() || !this._editorRef.isCaretAtStart()) { return; } const previousEvent = findEditableEvent(this._getRoom(), false, this.props.editState.getEvent().getId()); @@ -239,7 +148,7 @@ export default class MessageEditor extends React.Component { event.preventDefault(); } } else if (event.key === "ArrowDown") { - if (this._modifiedFlag || !this._isCaretAtEnd()) { + if (this._editorRef.isModified() || !this._editorRef.isCaretAtEnd()) { return; } const nextEvent = findEditableEvent(this._getRoom(), true, this.props.editState.getEvent().getId()); @@ -258,10 +167,10 @@ export default class MessageEditor extends React.Component { dis.dispatch({action: 'focus_composer'}); } - _hasModifications(newContent) { + _isModifiedOrSameAsOld(newContent) { // if nothing has changed then bail const oldContent = this.props.editState.getEvent().getContent(); - if (!this._modifiedFlag || + if (!this._editorRef.isModified() || (oldContent["msgtype"] === newContent["msgtype"] && oldContent["body"] === newContent["body"] && oldContent["format"] === newContent["format"] && oldContent["formatted_body"] === newContent["formatted_body"])) { @@ -274,7 +183,7 @@ export default class MessageEditor extends React.Component { const editedEvent = this.props.editState.getEvent(); const editContent = createEditContent(this.model, editedEvent); const newContent = editContent["m.new_content"]; - if (!this._hasModifications(newContent)) { + if (!this._isModifiedOrSameAsOld(newContent)) { return; } const roomId = editedEvent.getRoomId(); @@ -296,40 +205,21 @@ export default class MessageEditor extends React.Component { } } - _onAutoCompleteConfirm = (completion) => { - this.model.autoComplete.onComponentConfirm(completion); - } - - _onAutoCompleteSelectionChange = (completion) => { - this.model.autoComplete.onComponentSelectionChange(completion); - } - componentWillUnmount() { - this._editorRef.removeEventListener("input", this._onInput, true); const sel = document.getSelection(); const {caret} = getCaretOffsetAndText(this._editorRef, sel); const parts = this.model.serializeParts(); this.props.editState.setEditorState(caret, parts); } - componentDidMount() { + componentWillMount() { this._createEditorModel(); - // initial render of model - this._updateEditorState(this._getInitialCaretPosition()); - // attach input listener by hand so React doesn't proxy the events, - // as the proxied event doesn't support inputType, which we need. - this._editorRef.addEventListener("input", this._onInput, true); - this._editorRef.focus(); } _createEditorModel() { const {editState} = this.props; const room = this._getRoom(); - const partCreator = new PartCreator( - autoCompleteCreator(() => this._autocompleteRef, query => this.setState({query})), - room, - this.context.matrixClient, - ); + const partCreator = new PartCreator(room, this.context.matrixClient); let parts; if (editState.hasEditorState()) { // if restoring state from a previous editor, @@ -339,13 +229,7 @@ export default class MessageEditor extends React.Component { // otherwise, parse the body of the event parts = parseEvent(editState.getEvent(), partCreator); } - - this.historyManager = new HistoryManager(partCreator); - this.model = new EditorModel( - parts, - partCreator, - this._updateEditorState, - ); + this.model = new EditorModel(parts, partCreator); } _getInitialCaretPosition() { @@ -364,32 +248,14 @@ export default class MessageEditor extends React.Component { } render() { - let autoComplete; - if (this.state.autoComplete) { - const query = this.state.query; - const queryLen = query.length; - autoComplete =
- this._autocompleteRef = ref} - query={query} - onConfirm={this._onAutoCompleteConfirm} - onSelectionChange={this._onAutoCompleteSelectionChange} - selection={{beginning: true, end: queryLen, start: queryLen}} - room={this.state.room} - /> -
; - } const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); - return
- { autoComplete } -
this._editorRef = ref} - aria-label={_t("Edit message")} - >
+ return
+
{_t("Cancel")} {_t("Save")} diff --git a/src/editor/model.js b/src/editor/model.js index 5c0b69bf03..74546b9bf8 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -24,9 +24,17 @@ export default class EditorModel { this._activePartIdx = null; this._autoComplete = null; this._autoCompletePartIdx = null; + this.setUpdateCallback(updateCallback); + } + + setUpdateCallback(updateCallback) { this._updateCallback = updateCallback; } + get partCreator() { + return this._partCreator; + } + clone() { return new EditorModel(this._parts, this._partCreator, this._updateCallback); } diff --git a/src/editor/parts.js b/src/editor/parts.js index 4870042fe6..2a6ad81b9b 100644 --- a/src/editor/parts.js +++ b/src/editor/parts.js @@ -325,7 +325,7 @@ class PillCandidatePart extends PlainPart { } createAutoComplete(updateCallback) { - return this._autoCompleteCreator(updateCallback); + return this._autoCompleteCreator.create(updateCallback); } acceptsInsertion(chr, i) { @@ -363,10 +363,14 @@ export function autoCompleteCreator(getAutocompleterComponent, updateQuery) { } export class PartCreator { - constructor(autoCompleteCreator, room, client) { + constructor(room, client, autoCompleteCreator) { this._room = room; this._client = client; - this._autoCompleteCreator = autoCompleteCreator(this); + this._autoCompleteCreator = {create: autoCompleteCreator && autoCompleteCreator(this)}; + } + + setAutoCompleteCreator(autoCompleteCreator) { + this._autoCompleteCreator.create = autoCompleteCreator(this); } createPartForInput(input) { diff --git a/test/editor/mock.js b/test/editor/mock.js index 57ad0c52f3..7e0fd6b273 100644 --- a/test/editor/mock.js +++ b/test/editor/mock.js @@ -65,5 +65,5 @@ export function createPartCreator(completions = []) { const autoCompleteCreator = (partCreator) => { return (updateCallback) => new MockAutoComplete(updateCallback, partCreator, completions); }; - return new PartCreator(autoCompleteCreator, new MockRoom(), new MockClient()); + return new PartCreator(new MockRoom(), new MockClient(), autoCompleteCreator); } From a24e41c34f0d0afb2d7b44c660d5dc4d70532890 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 5 Aug 2019 15:33:42 +0200 Subject: [PATCH 2/2] make i18n build pass (unrelated) --- src/i18n/strings/en_EN.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 9ad20bf56c..f9afc36bb4 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -696,6 +696,7 @@ " (unsupported)": " (unsupported)", "Join as voice or video.": "Join as voice or video.", "Ongoing conference call%(supportedText)s.": "Ongoing conference call%(supportedText)s.", + "Edit message": "Edit message", "Some devices for this user are not trusted": "Some devices for this user are not trusted", "Some devices in this encrypted room are not trusted": "Some devices in this encrypted room are not trusted", "All devices for this user are trusted": "All devices for this user are trusted", @@ -1087,7 +1088,6 @@ "%(oneUser)smade no changes %(count)s times|one": "%(oneUser)smade no changes", "collapse": "collapse", "expand": "expand", - "Edit message": "Edit message", "Power level": "Power level", "Custom level": "Custom level", "Unable to load event that was replied to, it either does not exist or you do not have permission to view it.": "Unable to load event that was replied to, it either does not exist or you do not have permission to view it.",