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);
}