From 6599d605cda00e02f35cfce00b9bbfc8ad390d37 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 6 May 2019 17:41:15 +0200 Subject: [PATCH 01/49] wire up editor component (somewhat hacky) --- res/css/_components.scss | 1 + res/css/views/elements/_MessageEditor.scss | 42 ++++++++ res/css/views/messages/_MessageActionBar.scss | 4 + res/img/edit.svg | 97 +++++++++++++++++++ src/components/structures/MessagePanel.js | 5 + src/components/structures/TimelinePanel.js | 4 + .../views/elements/MessageEditor.js | 56 +++++++++++ .../views/messages/MessageActionBar.js | 13 +++ 8 files changed, 222 insertions(+) create mode 100644 res/css/views/elements/_MessageEditor.scss create mode 100644 res/img/edit.svg create mode 100644 src/components/views/elements/MessageEditor.js diff --git a/res/css/_components.scss b/res/css/_components.scss index 6e681894e3..2e0c91bd8c 100644 --- a/res/css/_components.scss +++ b/res/css/_components.scss @@ -89,6 +89,7 @@ @import "./views/elements/_InlineSpinner.scss"; @import "./views/elements/_ManageIntegsButton.scss"; @import "./views/elements/_MemberEventListSummary.scss"; +@import "./views/elements/_MessageEditor.scss"; @import "./views/elements/_PowerSelector.scss"; @import "./views/elements/_ProgressBar.scss"; @import "./views/elements/_ReplyThread.scss"; diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss new file mode 100644 index 0000000000..57ae79da8c --- /dev/null +++ b/res/css/views/elements/_MessageEditor.scss @@ -0,0 +1,42 @@ +/* +Copyright 2019 Vector Creations Ltd + +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. +*/ + +.mx_MessageEditor { + border-radius: 4px; + background-color: #f3f8fd; + padding: 10px; + + .editor { + border-radius: 4px; + border: solid 1px #e9edf1; + background-color: #ffffff; + } + + .buttons { + display: flex; + flex-direction: column; + align-items: end; + padding: 5px 0; + + .mx_AccessibleButton { + background-color: $button-bg-color; + border-radius: 4px; + padding: 5px 40px; + color: $button-fg-color; + font-weight: 600; + } + } +} diff --git a/res/css/views/messages/_MessageActionBar.scss b/res/css/views/messages/_MessageActionBar.scss index 419542036e..a0240c8171 100644 --- a/res/css/views/messages/_MessageActionBar.scss +++ b/res/css/views/messages/_MessageActionBar.scss @@ -69,6 +69,10 @@ limitations under the License. mask-image: url('$(res)/img/reply.svg'); } +.mx_MessageActionBar_editButton::after { + mask-image: url('$(res)/img/edit.svg'); +} + .mx_MessageActionBar_optionsButton::after { mask-image: url('$(res)/img/icon_context.svg'); } diff --git a/res/img/edit.svg b/res/img/edit.svg new file mode 100644 index 0000000000..15b5ef9563 --- /dev/null +++ b/res/img/edit.svg @@ -0,0 +1,97 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + diff --git a/src/components/structures/MessagePanel.js b/src/components/structures/MessagePanel.js index 2037217710..adc78d7032 100644 --- a/src/components/structures/MessagePanel.js +++ b/src/components/structures/MessagePanel.js @@ -450,9 +450,14 @@ module.exports = React.createClass({ _getTilesForEvent: function(prevEvent, mxEv, last) { const EventTile = sdk.getComponent('rooms.EventTile'); + const MessageEditor = sdk.getComponent('elements.MessageEditor'); const DateSeparator = sdk.getComponent('messages.DateSeparator'); const ret = []; + if (this.props.editEvent && this.props.editEvent.getId() === mxEv.getId()) { + return []; + } + // is this a continuation of the previous message? let continuation = false; diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 17a062be98..6529e92256 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -402,6 +402,9 @@ const TimelinePanel = React.createClass({ if (payload.action === 'ignore_state_changed') { this.forceUpdate(); } + if (payload.action === "edit_event") { + this.setState({editEvent: payload.event}); + } }, onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) { @@ -1244,6 +1247,7 @@ const TimelinePanel = React.createClass({ tileShape={this.props.tileShape} resizeNotifier={this.props.resizeNotifier} getRelationsForEvent={this.getRelationsForEvent} + editEvent={this.state.editEvent} /> ); }, diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js new file mode 100644 index 0000000000..f57521dbe9 --- /dev/null +++ b/src/components/views/elements/MessageEditor.js @@ -0,0 +1,56 @@ +/* +Copyright 2019 New Vector Ltd + +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 sdk from '../../../index'; +import {_t} from '../../../languageHandler'; +import PropTypes from 'prop-types'; +import dis from '../../../dispatcher'; +import {MatrixEvent, MatrixClient} from 'matrix-js-sdk'; + +export default class MessageEditor extends React.Component { + static propTypes = { + // the latest event in this chain of replies + event: PropTypes.instanceOf(MatrixEvent).isRequired, + // called when the ReplyThread contents has changed, including EventTiles thereof + // onHeightChanged: PropTypes.func.isRequired, + }; + + static contextTypes = { + matrixClient: PropTypes.instanceOf(MatrixClient).isRequired, + }; + + constructor(props, context) { + super(props, context); + this.state = {}; + this._onCancelClicked = this._onCancelClicked.bind(this); + } + + _onCancelClicked() { + dis.dispatch({action: "edit_event", event: null}); + } + + render() { + const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); + return
+
+ {this.props.event.getContent().body} +
+
+ {_t("Cancel")} +
+
; + } +} diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index 52630d7b0e..c4b8c441bd 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -58,6 +58,13 @@ export default class MessageActionBar extends React.PureComponent { }); } + onEditClick = (ev) => { + dis.dispatch({ + action: 'edit_event', + event: this.props.mxEvent, + }); + } + onOptionsClick = (ev) => { const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu'); const buttonRect = ev.target.getBoundingClientRect(); @@ -128,6 +135,7 @@ export default class MessageActionBar extends React.PureComponent { let agreeDimensionReactionButtons; let likeDimensionReactionButtons; let replyButton; + let editButton; if (isContentActionable(this.props.mxEvent)) { agreeDimensionReactionButtons = this.renderAgreeDimension(); @@ -136,12 +144,17 @@ export default class MessageActionBar extends React.PureComponent { title={_t("Reply")} onClick={this.onReplyClick} />; + editButton = ; } return
{agreeDimensionReactionButtons} {likeDimensionReactionButtons} {replyButton} + {editButton} Date: Mon, 6 May 2019 18:21:28 +0200 Subject: [PATCH 02/49] add converted prototype code --- src/editor/caret.js | 78 ++++++++++++++++++++ src/editor/diff.js | 78 ++++++++++++++++++++ src/editor/model.js | 169 ++++++++++++++++++++++++++++++++++++++++++ src/editor/parts.js | 174 ++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 499 insertions(+) create mode 100644 src/editor/caret.js create mode 100644 src/editor/diff.js create mode 100644 src/editor/model.js create mode 100644 src/editor/parts.js diff --git a/src/editor/caret.js b/src/editor/caret.js new file mode 100644 index 0000000000..3b803f35c3 --- /dev/null +++ b/src/editor/caret.js @@ -0,0 +1,78 @@ +/* +Copyright 2019 New Vector Ltd + +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 function getCaretPosition(editor) { + const sel = document.getSelection(); + const atNodeEnd = sel.focusOffset === sel.focusNode.textContent.length; + let position = sel.focusOffset; + let node = sel.focusNode; + + // when deleting the last character of a node, + // the caret gets reported as being after the focusOffset-th node, + // with the focusNode being the editor + if (node === editor) { + let position = 0; + for (let i = 0; i < sel.focusOffset; ++i) { + position += editor.childNodes[i].textContent.length; + } + return {position, atNodeEnd: false}; + } + + // first make sure we're at the level of a direct child of editor + if (node.parentElement !== editor) { + // include all preceding siblings of the non-direct editor children + while (node.previousSibling) { + node = node.previousSibling; + position += node.textContent.length; + } + // then move up + // I guess technically there could be preceding text nodes in the parents here as well, + // but we're assuming there are no mixed text and element nodes + while (node.parentElement !== editor) { + node = node.parentElement; + } + } + // now include the text length of all preceding direct editor children + while (node.previousSibling) { + node = node.previousSibling; + position += node.textContent.length; + } + { + const {focusOffset, focusNode} = sel; + console.log("selection", {focusOffset, focusNode, position, atNodeEnd}); + } + return {position, atNodeEnd}; +} + +export function setCaretPosition(editor, caretPosition) { + if (caretPosition) { + let focusNode = editor.childNodes[caretPosition.index]; + if (!focusNode) { + focusNode = editor; + } else { + // make sure we have a text node + if (focusNode.nodeType === Node.ELEMENT_NODE) { + focusNode = focusNode.childNodes[0]; + } + } + const sel = document.getSelection(); + sel.removeAllRanges(); + const range = document.createRange(); + range.setStart(focusNode, caretPosition.offset); + range.collapse(true); + sel.addRange(range); + } +} diff --git a/src/editor/diff.js b/src/editor/diff.js new file mode 100644 index 0000000000..6dc8b746e4 --- /dev/null +++ b/src/editor/diff.js @@ -0,0 +1,78 @@ +/* +Copyright 2019 New Vector Ltd + +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. +*/ + +function firstDiff(a, b) { + const compareLen = Math.min(a.length, b.length); + for (let i = 0; i < compareLen; ++i) { + if (a[i] !== b[i]) { + return i; + } + } + return compareLen; +} + +function lastDiff(a, b) { + const compareLen = Math.min(a.length, b.length); + for (let i = 0; i < compareLen; ++i) { + if (a[a.length - i] !== b[b.length - i]) { + return i; + } + } + return compareLen; +} + +function diffStringsAtEnd(oldStr, newStr) { + const len = Math.min(oldStr.length, newStr.length); + const startInCommon = oldStr.substr(0, len) === newStr.substr(0, len); + if (startInCommon && oldStr.length > newStr.length) { + return {removed: oldStr.substr(len), at: len}; + } else if (startInCommon && oldStr.length < newStr.length) { + return {added: newStr.substr(len), at: len}; + } else { + const commonStartLen = firstDiff(oldStr, newStr); + return { + removed: oldStr.substr(commonStartLen), + added: newStr.substr(commonStartLen), + at: commonStartLen, + }; + } +} + +export function diffDeletion(oldStr, newStr) { + if (oldStr === newStr) { + return {}; + } + const firstDiffIdx = firstDiff(oldStr, newStr); + const lastDiffIdx = oldStr.length - lastDiff(oldStr, newStr) + 1; + return {at: firstDiffIdx, removed: oldStr.substring(firstDiffIdx, lastDiffIdx)}; +} + +export function diffInsertion(oldStr, newStr) { + const diff = diffDeletion(newStr, oldStr); + if (diff.removed) { + return {at: diff.at, added: diff.removed}; + } else { + return diff; + } +} + +export function diffAtCaret(oldValue, newValue, caretPosition) { + const diffLen = newValue.length - oldValue.length; + const caretPositionBeforeInput = caretPosition - diffLen; + const oldValueBeforeCaret = oldValue.substr(0, caretPositionBeforeInput); + const newValueBeforeCaret = newValue.substr(0, caretPosition); + return diffStringsAtEnd(oldValueBeforeCaret, newValueBeforeCaret); +} diff --git a/src/editor/model.js b/src/editor/model.js new file mode 100644 index 0000000000..ffd2e17c01 --- /dev/null +++ b/src/editor/model.js @@ -0,0 +1,169 @@ +/* +Copyright 2019 New Vector Ltd + +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 {PlainPart, RoomPillPart, UserPillPart} from "./parts"; +import {diffAtCaret, diffDeletion} from "./diff"; + +export default class EditorModel { + constructor(parts = []) { + this._parts = parts; + this.actions = null; + this._previousValue = parts.reduce((text, p) => text + p.text, ""); + } + + _insertPart(index, part) { + this._parts.splice(index, 0, part); + } + + _removePart(index) { + this._parts.splice(index, 1); + } + + _replacePart(index, part) { + this._parts.splice(index, 1, part); + } + + get parts() { + return this._parts; + } + + _diff(newValue, inputType, caret) { + if (inputType === "deleteByDrag") { + return diffDeletion(this._previousValue, newValue); + } else { + return diffAtCaret(this._previousValue, newValue, caret.position); + } + } + + update(newValue, inputType, caret) { + const diff = this._diff(newValue, inputType, caret); + const position = this._positionForOffset(diff.at, caret.atNodeEnd); + console.log("update at", {position, diff}); + if (diff.removed) { + this._removeText(position, diff.removed.length); + } + if (diff.added) { + this._addText(position, diff.added); + } + this._mergeAdjacentParts(); + this._previousValue = newValue; + const caretOffset = diff.at + (diff.added ? diff.added.length : 0); + return this._positionForOffset(caretOffset, true); + } + + _mergeAdjacentParts(docPos) { + let prevPart = this._parts[0]; + for (let i = 1; i < this._parts.length; ++i) { + let part = this._parts[i]; + const isEmpty = !part.text.length; + const isMerged = !isEmpty && prevPart.merge(part); + if (isEmpty || isMerged) { + // remove empty or merged part + part = prevPart; + this._removePart(i); + //repeat this index, as it's removed now + --i; + } + prevPart = part; + } + } + + _removeText(pos, len) { + let {index, offset} = pos; + while (len !== 0) { + // part might be undefined here + let part = this._parts[index]; + const amount = Math.min(len, part.text.length - offset); + const replaceWith = part.remove(offset, amount); + if (typeof replaceWith === "string") { + this._replacePart(index, new PlainPart(replaceWith)); + } + part = this._parts[index]; + // remove empty part + if (!part.text.length) { + this._removePart(index); + } else { + index += 1; + } + len -= amount; + offset = 0; + } + } + + _addText(pos, str, actions) { + let {index, offset} = pos; + const part = this._parts[index]; + if (part) { + if (part.insertAll(offset, str)) { + str = null; + } else { + // console.log("splitting", offset, [part.text]); + const splitPart = part.split(offset); + // console.log("splitted", [part.text, splitPart.text]); + index += 1; + this._insertPart(index, splitPart); + } + } + while (str) { + let newPart; + switch (str[0]) { + case "#": + newPart = new RoomPillPart(); + break; + case "@": + newPart = new UserPillPart(); + break; + default: + newPart = new PlainPart(); + } + str = newPart.appendUntilRejected(str); + this._insertPart(index, newPart); + index += 1; + } + } + + _positionForOffset(totalOffset, atPartEnd) { + let currentOffset = 0; + const index = this._parts.findIndex(part => { + const partLen = part.text.length; + if ( + (atPartEnd && (currentOffset + partLen) >= totalOffset) || + (!atPartEnd && (currentOffset + partLen) > totalOffset) + ) { + return true; + } + currentOffset += partLen; + return false; + }); + + return new DocumentPosition(index, totalOffset - currentOffset); + } +} + +class DocumentPosition { + constructor(index, offset) { + this._index = index; + this._offset = offset; + } + + get index() { + return this._index; + } + + get offset() { + return this._offset; + } +} diff --git a/src/editor/parts.js b/src/editor/parts.js new file mode 100644 index 0000000000..be5326d98f --- /dev/null +++ b/src/editor/parts.js @@ -0,0 +1,174 @@ +/* +Copyright 2019 New Vector Ltd + +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. +*/ + +class BasePart { + constructor(text = "") { + this._text = text; + } + + acceptsInsertion(chr) { + return true; + } + + acceptsRemoval(position, chr) { + return true; + } + + merge(part) { + return false; + } + + split(offset) { + const splitText = this.text.substr(offset); + this._text = this.text.substr(0, offset); + return new PlainPart(splitText); + } + + // removes len chars, or returns the plain text this part should be replaced with + // if the part would become invalid if it removed everything. + + // TODO: this should probably return the Part and caret position within this should be replaced with + remove(offset, len) { + // validate + const strWithRemoval = this.text.substr(0, offset) + this.text.substr(offset + len); + for(let i = offset; i < (len + offset); ++i) { + const chr = this.text.charAt(i); + if (!this.acceptsRemoval(i, chr)) { + return strWithRemoval; + } + } + this._text = strWithRemoval; + } + + // append str, returns the remaining string if a character was rejected. + appendUntilRejected(str) { + for(let i = 0; i < str.length; ++i) { + const chr = str.charAt(i); + if (!this.acceptsInsertion(chr)) { + this._text = this._text + str.substr(0, i); + return str.substr(i); + } + } + this._text = this._text + str; + } + + // inserts str at offset if all the characters in str were accepted, otherwise don't do anything + // return whether the str was accepted or not. + insertAll(offset, str) { + for(let i = 0; i < str.length; ++i) { + const chr = str.charAt(i); + if (!this.acceptsInsertion(chr)) { + return false; + } + } + const beforeInsert = this._text.substr(0, offset); + const afterInsert = this._text.substr(offset); + this._text = beforeInsert + str + afterInsert; + return true; + } + + + trim(len) { + const remaining = this._text.substr(len); + this._text = this._text.substr(0, len); + return remaining; + } + + get text() { + return this._text; + } +} + +export class PlainPart extends BasePart { + acceptsInsertion(chr) { + return chr !== "@" && chr !== "#"; + } + + toDOMNode() { + return document.createTextNode(this.text); + } + + merge(part) { + if (part.type === this.type) { + this._text = this.text + part.text; + return true; + } + return false; + } + + get type() { + return "plain"; + } + + updateDOMNode(node) { + if (node.textContent !== this.text) { + // console.log("changing plain text from", node.textContent, "to", this.text); + node.textContent = this.text; + } + } + + canUpdateDOMNode(node) { + return node.nodeType === Node.TEXT_NODE; + } +} + +class PillPart extends BasePart { + acceptsInsertion(chr) { + return chr !== " "; + } + + acceptsRemoval(position, chr) { + return position !== 0; //if you remove initial # or @, pill should become plain + } + + toDOMNode() { + const container = document.createElement("span"); + container.className = this.type; + container.appendChild(document.createTextNode(this.text)); + return container; + } + + updateDOMNode(node) { + const textNode = node.childNodes[0]; + if (textNode.textContent !== this.text) { + // console.log("changing pill text from", textNode.textContent, "to", this.text); + textNode.textContent = this.text; + } + if (node.className !== this.type) { + // console.log("turning", node.className, "into", this.type); + node.className = this.type; + } + } + + canUpdateDOMNode(node) { + return node.nodeType === Node.ELEMENT_NODE && + node.nodeName === "SPAN" && + node.childNodes.length === 1 && + node.childNodes[0].nodeType === Node.TEXT_NODE; + } +} + +export class RoomPillPart extends PillPart { + get type() { + return "room-pill"; + } +} + +export class UserPillPart extends PillPart { + get type() { + return "user-pill"; + } +} From 76bb56a2bf4b9a9da482480349c29fbe6680f52b Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 7 May 2019 16:27:09 +0200 Subject: [PATCH 03/49] initial hookup editor code with react component --- res/css/views/elements/_MessageEditor.scss | 26 +++++++++++++- .../views/elements/MessageEditor.js | 35 +++++++++++++++++-- src/editor/caret.js | 24 ++++++------- src/editor/model.js | 8 +++-- 4 files changed, 75 insertions(+), 18 deletions(-) diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss index 57ae79da8c..eefb45afe5 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/elements/_MessageEditor.scss @@ -17,12 +17,28 @@ limitations under the License. .mx_MessageEditor { border-radius: 4px; background-color: #f3f8fd; - padding: 10px; + padding: 11px 13px 7px 56px; .editor { border-radius: 4px; border: solid 1px #e9edf1; background-color: #ffffff; + padding: 10px; + + span { + display: inline-block; + padding: 0 5px; + border-radius: 4px; + color: white; + } + + span.user-pill { + background: red; + } + + span.room-pill { + background: green; + } } .buttons { @@ -39,4 +55,12 @@ limitations under the License. font-weight: 600; } } + + .model { + background: lightgrey; + padding: 5px; + display: block; + white-space: pre; + font-size: 12px; + } } diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index f57521dbe9..026f92238b 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -18,6 +18,9 @@ import sdk from '../../../index'; import {_t} from '../../../languageHandler'; import PropTypes from 'prop-types'; import dis from '../../../dispatcher'; +import EditorModel from '../../../editor/model'; +import {PlainPart} from '../../../editor/parts'; +import {getCaretOffset, setCaretPosition} from '../../../editor/caret'; import {MatrixEvent, MatrixClient} from 'matrix-js-sdk'; export default class MessageEditor extends React.Component { @@ -34,8 +37,24 @@ export default class MessageEditor extends React.Component { constructor(props, context) { super(props, context); - this.state = {}; + const body = this.props.event.getContent().body; + this.model = new EditorModel(); + this.model.update(body, undefined, {offset: body.length}); + this.state = { + parts: this.model.serializeParts(), + }; this._onCancelClicked = this._onCancelClicked.bind(this); + this._onInput = this._onInput.bind(this); + } + + _onInput(event) { + const editor = event.target; + const caretOffset = getCaretOffset(editor); + const caret = this.model.update(editor.textContent, event.inputType, caretOffset); + const parts = this.model.serializeParts(); + this.setState({parts}, () => { + setCaretPosition(editor, caret); + }); } _onCancelClicked() { @@ -43,14 +62,24 @@ export default class MessageEditor extends React.Component { } render() { + const parts = this.state.parts.map((p, i) => { + const key = `${i}-${p.type}`; + switch (p.type) { + case "plain": return p.text; + case "room-pill": return ({p.text}); + case "user-pill": return ({p.text}); + } + }); + const modelOutput = JSON.stringify(this.state.parts, undefined, 2); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return
-
- {this.props.event.getContent().body} +
+ {parts}
{_t("Cancel")}
+ {modelOutput}
; } } diff --git a/src/editor/caret.js b/src/editor/caret.js index 3b803f35c3..a252ebddc6 100644 --- a/src/editor/caret.js +++ b/src/editor/caret.js @@ -14,21 +14,21 @@ See the License for the specific language governing permissions and limitations under the License. */ -export function getCaretPosition(editor) { +export function getCaretOffset(editor) { const sel = document.getSelection(); const atNodeEnd = sel.focusOffset === sel.focusNode.textContent.length; - let position = sel.focusOffset; + let offset = sel.focusOffset; let node = sel.focusNode; // when deleting the last character of a node, // the caret gets reported as being after the focusOffset-th node, // with the focusNode being the editor if (node === editor) { - let position = 0; + let offset = 0; for (let i = 0; i < sel.focusOffset; ++i) { - position += editor.childNodes[i].textContent.length; + offset += editor.childNodes[i].textContent.length; } - return {position, atNodeEnd: false}; + return {offset, atNodeEnd: false}; } // first make sure we're at the level of a direct child of editor @@ -36,7 +36,7 @@ export function getCaretPosition(editor) { // include all preceding siblings of the non-direct editor children while (node.previousSibling) { node = node.previousSibling; - position += node.textContent.length; + offset += node.textContent.length; } // then move up // I guess technically there could be preceding text nodes in the parents here as well, @@ -48,13 +48,13 @@ export function getCaretPosition(editor) { // now include the text length of all preceding direct editor children while (node.previousSibling) { node = node.previousSibling; - position += node.textContent.length; + offset += node.textContent.length; } - { - const {focusOffset, focusNode} = sel; - console.log("selection", {focusOffset, focusNode, position, atNodeEnd}); - } - return {position, atNodeEnd}; + // { + // const {focusOffset, focusNode} = sel; + // console.log("selection", {focusOffset, focusNode, position, atNodeEnd}); + // } + return {offset, atNodeEnd}; } export function setCaretPosition(editor, caretPosition) { diff --git a/src/editor/model.js b/src/editor/model.js index ffd2e17c01..b3d6682f79 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -40,18 +40,22 @@ export default class EditorModel { return this._parts; } + serializeParts() { + return this._parts.map(({type, text}) => {return {type, text};}); + } + _diff(newValue, inputType, caret) { if (inputType === "deleteByDrag") { return diffDeletion(this._previousValue, newValue); } else { - return diffAtCaret(this._previousValue, newValue, caret.position); + return diffAtCaret(this._previousValue, newValue, caret.offset); } } update(newValue, inputType, caret) { const diff = this._diff(newValue, inputType, caret); const position = this._positionForOffset(diff.at, caret.atNodeEnd); - console.log("update at", {position, diff}); + console.log("update at", {position, diff, newValue, prevValue: this._previousValue}); if (diff.removed) { this._removeText(position, diff.removed.length); } From 6be6492cd285c8627305ccdaba85881bb4253cbd Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 7 May 2019 17:31:37 +0200 Subject: [PATCH 04/49] initial parsing of pills for editor --- .../views/elements/MessageEditor.js | 6 +- src/editor/parse-event.js | 57 +++++++++++++++++++ 2 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 src/editor/parse-event.js diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index 026f92238b..104e805c05 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -19,8 +19,8 @@ import {_t} from '../../../languageHandler'; import PropTypes from 'prop-types'; import dis from '../../../dispatcher'; import EditorModel from '../../../editor/model'; -import {PlainPart} from '../../../editor/parts'; import {getCaretOffset, setCaretPosition} from '../../../editor/caret'; +import parseEvent from '../../../editor/parse-event'; import {MatrixEvent, MatrixClient} from 'matrix-js-sdk'; export default class MessageEditor extends React.Component { @@ -37,9 +37,7 @@ export default class MessageEditor extends React.Component { constructor(props, context) { super(props, context); - const body = this.props.event.getContent().body; - this.model = new EditorModel(); - this.model.update(body, undefined, {offset: body.length}); + this.model = new EditorModel(parseEvent(this.props.event)); this.state = { parts: this.model.serializeParts(), }; diff --git a/src/editor/parse-event.js b/src/editor/parse-event.js new file mode 100644 index 0000000000..711529defe --- /dev/null +++ b/src/editor/parse-event.js @@ -0,0 +1,57 @@ +/* +Copyright 2019 New Vector Ltd + +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 { MATRIXTO_URL_PATTERN } from '../linkify-matrix'; +import { PlainPart, UserPillPart, RoomPillPart } from "./parts"; + +function parseHtmlMessage(html) { + const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN); + const nodes = Array.from(new DOMParser().parseFromString(html, "text/html").body.childNodes); + const parts = nodes.map(n => { + switch (n.nodeType) { + case Node.TEXT_NODE: + return new PlainPart(n.nodeValue); + case Node.ELEMENT_NODE: + switch (n.nodeName) { + case "MX-REPLY": + return null; + case "A": { + const {href} = n; + const pillMatch = REGEX_MATRIXTO.exec(href) || []; + const resourceId = pillMatch[1]; // The room/user ID + const prefix = pillMatch[2]; // The first character of prefix + switch (prefix) { + case "@": return new UserPillPart(resourceId); + case "#": return new RoomPillPart(resourceId); + default: return new PlainPart(n.innerText); + } + } + default: + return new PlainPart(n.innerText); + } + } + }).filter(p => !!p); + return parts; +} + +export default function parseEvent(event) { + const content = event.getContent(); + if (content.format === "org.matrix.custom.html") { + return parseHtmlMessage(content.formatted_body); + } else { + return [new PlainPart(content.body)]; + } +} From 8f0074f824ecd198072c81aceeadec5ca06dd5b1 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 8 May 2019 11:12:47 +0200 Subject: [PATCH 05/49] ignore react comment nodes when locating/setting caret --- src/editor/caret.js | 67 +++++++++++++++++++++++++++++++-------------- 1 file changed, 46 insertions(+), 21 deletions(-) diff --git a/src/editor/caret.js b/src/editor/caret.js index a252ebddc6..1d437dd083 100644 --- a/src/editor/caret.js +++ b/src/editor/caret.js @@ -26,7 +26,10 @@ export function getCaretOffset(editor) { if (node === editor) { let offset = 0; for (let i = 0; i < sel.focusOffset; ++i) { - offset += editor.childNodes[i].textContent.length; + const node = editor.childNodes[i]; + if (isVisibleNode(node)) { + offset += node.textContent.length; + } } return {offset, atNodeEnd: false}; } @@ -36,7 +39,9 @@ export function getCaretOffset(editor) { // include all preceding siblings of the non-direct editor children while (node.previousSibling) { node = node.previousSibling; - offset += node.textContent.length; + if (isVisibleNode(node)) { + offset += node.textContent.length; + } } // then move up // I guess technically there could be preceding text nodes in the parents here as well, @@ -48,7 +53,9 @@ export function getCaretOffset(editor) { // now include the text length of all preceding direct editor children while (node.previousSibling) { node = node.previousSibling; - offset += node.textContent.length; + if (isVisibleNode(node)) { + offset += node.textContent.length; + } } // { // const {focusOffset, focusNode} = sel; @@ -57,22 +64,40 @@ export function getCaretOffset(editor) { return {offset, atNodeEnd}; } -export function setCaretPosition(editor, caretPosition) { - if (caretPosition) { - let focusNode = editor.childNodes[caretPosition.index]; - if (!focusNode) { - focusNode = editor; - } else { - // make sure we have a text node - if (focusNode.nodeType === Node.ELEMENT_NODE) { - focusNode = focusNode.childNodes[0]; - } - } - const sel = document.getSelection(); - sel.removeAllRanges(); - const range = document.createRange(); - range.setStart(focusNode, caretPosition.offset); - range.collapse(true); - sel.addRange(range); - } +function isVisibleNode(node) { + return node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE; +} + +function untilVisibleNode(node) { + // need to ignore comment nodes that react uses + while (node && !isVisibleNode(node)) { + node = node.nextSibling; + } + return node; +} + +export function setCaretPosition(editor, caretPosition) { + let node = untilVisibleNode(editor.firstChild); + if (!node) { + node = editor; + } else { + let {index} = caretPosition; + while (node && index) { + node = untilVisibleNode(node.nextSibling); + --index; + } + if (!node) { + node = editor; + } else if (node.nodeType === Node.ELEMENT_NODE) { + // make sure we have a text node + node = node.childNodes[0]; + } + } + console.log("setting caret", caretPosition, node); + const sel = document.getSelection(); + sel.removeAllRanges(); + const range = document.createRange(); + range.setStart(node, caretPosition.offset); + range.collapse(true); + sel.addRange(range); } From ebdb9fcb9c8c0b3eb6cba8ab9cd63e114bcfef39 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 8 May 2019 11:13:13 +0200 Subject: [PATCH 06/49] don't collapse whitespace in editor --- res/css/views/elements/_MessageEditor.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss index eefb45afe5..b3b73e88e3 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/elements/_MessageEditor.scss @@ -24,6 +24,7 @@ limitations under the License. border: solid 1px #e9edf1; background-color: #ffffff; padding: 10px; + white-space: pre; span { display: inline-block; From 0f38753dba3848a9a190805103795710fbb15f7a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 8 May 2019 11:13:36 +0200 Subject: [PATCH 07/49] some comments --- src/editor/model.js | 1 + src/editor/parse-event.js | 3 +++ 2 files changed, 4 insertions(+) diff --git a/src/editor/model.js b/src/editor/model.js index b3d6682f79..e4170d88dd 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -45,6 +45,7 @@ export default class EditorModel { } _diff(newValue, inputType, caret) { + // can't use caret position with drag and drop if (inputType === "deleteByDrag") { return diffDeletion(this._previousValue, newValue); } else { diff --git a/src/editor/parse-event.js b/src/editor/parse-event.js index 711529defe..b4dc22ee4e 100644 --- a/src/editor/parse-event.js +++ b/src/editor/parse-event.js @@ -19,6 +19,9 @@ import { PlainPart, UserPillPart, RoomPillPart } from "./parts"; function parseHtmlMessage(html) { const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN); + // no nodes from parsing here should be inserted in the document, + // as scripts in event handlers, etc would be executed then. + // we're only taking text, so that is fine const nodes = Array.from(new DOMParser().parseFromString(html, "text/html").body.childNodes); const parts = nodes.map(n => { switch (n.nodeType) { From 85adc8953f68dd99abca86197f488f3d8872e911 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 8 May 2019 11:14:08 +0200 Subject: [PATCH 08/49] remove logging --- src/editor/caret.js | 1 - 1 file changed, 1 deletion(-) diff --git a/src/editor/caret.js b/src/editor/caret.js index 1d437dd083..a8fe3ddc68 100644 --- a/src/editor/caret.js +++ b/src/editor/caret.js @@ -93,7 +93,6 @@ export function setCaretPosition(editor, caretPosition) { node = node.childNodes[0]; } } - console.log("setting caret", caretPosition, node); const sel = document.getSelection(); sel.removeAllRanges(); const range = document.createRange(); From a2f1f49972dddf268dd24f9d8c3fb8753a4ffbd6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 8 May 2019 14:31:43 +0200 Subject: [PATCH 09/49] update the DOM manually as opposed through react rendering react messes up the DOM sometimes because of, I assume, not being aware of the changes to the real DOM by contenteditable. --- .../views/elements/MessageEditor.js | 73 ++++++++++++------- src/editor/render.js | 52 +++++++++++++ 2 files changed, 99 insertions(+), 26 deletions(-) create mode 100644 src/editor/render.js diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index 104e805c05..be44d4ffa8 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -21,6 +21,7 @@ import dis from '../../../dispatcher'; import EditorModel from '../../../editor/model'; import {getCaretOffset, setCaretPosition} from '../../../editor/caret'; import parseEvent from '../../../editor/parse-event'; +import {renderModel, rerenderModel} from '../../../editor/render'; import {MatrixEvent, MatrixClient} from 'matrix-js-sdk'; export default class MessageEditor extends React.Component { @@ -38,46 +39,66 @@ export default class MessageEditor extends React.Component { constructor(props, context) { super(props, context); this.model = new EditorModel(parseEvent(this.props.event)); - this.state = { - parts: this.model.serializeParts(), - }; - this._onCancelClicked = this._onCancelClicked.bind(this); - this._onInput = this._onInput.bind(this); + this.state = {}; + this._editorRef = null; } - _onInput(event) { - const editor = event.target; - const caretOffset = getCaretOffset(editor); - const caret = this.model.update(editor.textContent, event.inputType, caretOffset); - const parts = this.model.serializeParts(); - this.setState({parts}, () => { - setCaretPosition(editor, caret); - }); + _onInput = (event) => { + const caretOffset = getCaretOffset(this._editorRef); + const caret = this.model.update(this._editorRef.textContent, event.inputType, caretOffset); + // const parts = this.model.serializeParts(); + const shouldRerender = event.inputType === "insertFromDrop" || event.inputType === "insertFromPaste"; + if (shouldRerender) { + rerenderModel(this._editorRef, this.model); + } else { + renderModel(this._editorRef, this.model); + } + setCaretPosition(this._editorRef, caret); + + const modelOutput = this._editorRef.parentElement.querySelector(".model"); + modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); } - _onCancelClicked() { + _onCancelClicked = () => { dis.dispatch({action: "edit_event", event: null}); } + _collectEditorRef = (ref) => { + this._editorRef = ref; + } + + componentDidMount() { + const editor = this._editorRef; + rerenderModel(editor, this.model); + const modelOutput = this._editorRef.parentElement.querySelector(".model"); + modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); + } + render() { - const parts = this.state.parts.map((p, i) => { - const key = `${i}-${p.type}`; - switch (p.type) { - case "plain": return p.text; - case "room-pill": return ({p.text}); - case "user-pill": return ({p.text}); - } - }); - const modelOutput = JSON.stringify(this.state.parts, undefined, 2); + // const parts = this.state.parts.map((p, i) => { + // const key = `${i}-${p.type}`; + // switch (p.type) { + // case "plain": return p.text; + // case "room-pill": return ({p.text}); + // case "user-pill": return ({p.text}); + // } + // }); + // const modelOutput = JSON.stringify(this.state.parts, undefined, 2); const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return
-
- {parts} +
{_t("Cancel")}
- {modelOutput} +
; } } diff --git a/src/editor/render.js b/src/editor/render.js new file mode 100644 index 0000000000..f7eb5d5c2b --- /dev/null +++ b/src/editor/render.js @@ -0,0 +1,52 @@ +/* +Copyright 2019 New Vector Ltd + +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 function rerenderModel(editor, model) { + while (editor.firstChild) { + editor.removeChild(editor.firstChild); + } + for (const part of model.parts) { + editor.appendChild(part.toDOMNode()); + } +} + +export function renderModel(editor, model) { + // remove unwanted nodes, like
s + for (let i = 0; i < model.parts.length; ++i) { + const part = model.parts[i]; + let node = editor.childNodes[i]; + while (node && !part.canUpdateDOMNode(node)) { + editor.removeChild(node); + node = editor.childNodes[i]; + } + } + for (let i = 0; i < model.parts.length; ++i) { + const part = model.parts[i]; + const node = editor.childNodes[i]; + if (node && part) { + part.updateDOMNode(node); + } else if (part) { + editor.appendChild(part.toDOMNode()); + } else if (node) { + editor.removeChild(node); + } + } + let surplusElementCount = Math.max(0, editor.childNodes.length - model.parts.length); + while (surplusElementCount) { + editor.removeChild(editor.lastChild); + --surplusElementCount; + } +} From a765fdf98abc6100fa39570bacfc53f2082cdb4a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 14:57:09 +0200 Subject: [PATCH 10/49] run autocomplete after mounting componentWillReceiveProps doesn't run after mount, and is deprecated as well. Update state after both on componentDidMount and componentDidUpdate --- src/components/views/rooms/Autocomplete.js | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index e75456ea50..253e2b411a 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -60,18 +60,22 @@ export default class Autocomplete extends React.Component { }; } - componentWillReceiveProps(newProps, state) { - if (this.props.room.roomId !== newProps.room.roomId) { + componentDidMount() { + this._applyNewProps(); + } + + _applyNewProps(oldQuery, oldRoom) { + if (oldRoom && this.props.room.roomId !== oldRoom.roomId) { this.autocompleter.destroy(); - this.autocompleter = new Autocompleter(newProps.room); + this.autocompleter = new Autocompleter(this.props.room); } // Query hasn't changed so don't try to complete it - if (newProps.query === this.props.query) { + if (oldQuery === this.props.query) { return; } - this.complete(newProps.query, newProps.selection); + this.complete(this.props.query, this.props.selection); } componentWillUnmount() { @@ -233,7 +237,8 @@ export default class Autocomplete extends React.Component { } } - componentDidUpdate() { + componentDidUpdate(prevProps) { + this._applyNewProps(prevProps.query, prevProps.room); // this is the selected completion, so scroll it into view if needed const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`]; if (selectedCompletion && this.container) { From 7507d0d7e15ef807203a0a48b346cec457122bff Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 14:58:13 +0200 Subject: [PATCH 11/49] complete proptypes --- src/components/views/rooms/Autocomplete.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js index 253e2b411a..a19a4eaad0 100644 --- a/src/components/views/rooms/Autocomplete.js +++ b/src/components/views/rooms/Autocomplete.js @@ -303,6 +303,9 @@ Autocomplete.propTypes = { // method invoked with range and text content when completion is confirmed onConfirm: PropTypes.func.isRequired, + // method invoked when selected (if any) completion changes + onSelectionChange: PropTypes.func, + // The room in which we're autocompleting room: PropTypes.instanceOf(Room), }; From 1330b438d640d3ad05110cb03f3164230fdbf0e8 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 14:59:52 +0200 Subject: [PATCH 12/49] initial support for auto complete in model and parts also move part creation out of model, into partcreator, which can then also contain dependencies for creating the auto completer. --- src/editor/model.js | 83 ++++++++++++++++++++++++++++++++++++--------- src/editor/parts.js | 70 ++++++++++++++++++++++++++++++++++---- 2 files changed, 131 insertions(+), 22 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index e4170d88dd..2bf78026b3 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -14,22 +14,36 @@ See the License for the specific language governing permissions and limitations under the License. */ -import {PlainPart, RoomPillPart, UserPillPart} from "./parts"; import {diffAtCaret, diffDeletion} from "./diff"; export default class EditorModel { - constructor(parts = []) { + constructor(parts, partCreator) { this._parts = parts; - this.actions = null; + this._partCreator = partCreator; this._previousValue = parts.reduce((text, p) => text + p.text, ""); + this._activePartIdx = null; + this._autoComplete = null; + this._autoCompletePartIdx = null; } _insertPart(index, part) { this._parts.splice(index, 0, part); + if (this._activePartIdx >= index) { + ++this._activePartIdx; + } + if (this._autoCompletePartIdx >= index) { + ++this._autoCompletePartIdx; + } } _removePart(index) { this._parts.splice(index, 1); + if (this._activePartIdx >= index) { + --this._activePartIdx; + } + if (this._autoCompletePartIdx >= index) { + --this._autoCompletePartIdx; + } } _replacePart(index, part) { @@ -40,11 +54,21 @@ export default class EditorModel { return this._parts; } + get autoComplete() { + if (this._activePartIdx === this._autoCompletePartIdx) { + return this._autoComplete; + } + return null; + } + serializeParts() { return this._parts.map(({type, text}) => {return {type, text};}); } _diff(newValue, inputType, caret) { + // handle deleteContentForward (Delete key) + // and deleteContentBackward (Backspace) + // can't use caret position with drag and drop if (inputType === "deleteByDrag") { return diffDeletion(this._previousValue, newValue); @@ -66,9 +90,38 @@ export default class EditorModel { this._mergeAdjacentParts(); this._previousValue = newValue; const caretOffset = diff.at + (diff.added ? diff.added.length : 0); - return this._positionForOffset(caretOffset, true); + const newPosition = this._positionForOffset(caretOffset, true); + this._setActivePart(newPosition); + return newPosition; } + _setActivePart(pos) { + const {index} = pos; + const part = this._parts[index]; + if (pos.index !== this._activePartIdx) { + this._activePartIdx = index; + // if there is a hidden autocomplete for this part, show it again + if (this._activePartIdx !== this._autoCompletePartIdx) { + // else try to create one + const ac = part.createAutoComplete(this._onAutoComplete); + if (ac) { + // make sure that react picks up the difference between both acs + this._autoComplete = ac; + this._autoCompletePartIdx = index; + } + } + } + if (this._autoComplete) { + this._autoComplete.onPartUpdate(part, pos.offset); + } + } + + /* + updateCaret(caret) { + // update active part here as well, hiding/showing autocomplete if needed + } + */ + _mergeAdjacentParts(docPos) { let prevPart = this._parts[0]; for (let i = 1; i < this._parts.length; ++i) { @@ -94,7 +147,7 @@ export default class EditorModel { const amount = Math.min(len, part.text.length - offset); const replaceWith = part.remove(offset, amount); if (typeof replaceWith === "string") { - this._replacePart(index, new PlainPart(replaceWith)); + this._replacePart(index, this._partCreator.createDefaultPart(replaceWith)); } part = this._parts[index]; // remove empty part @@ -123,17 +176,7 @@ export default class EditorModel { } } while (str) { - let newPart; - switch (str[0]) { - case "#": - newPart = new RoomPillPart(); - break; - case "@": - newPart = new UserPillPart(); - break; - default: - newPart = new PlainPart(); - } + const newPart = this._partCreator.createPartForInput(str); str = newPart.appendUntilRejected(str); this._insertPart(index, newPart); index += 1; @@ -156,6 +199,14 @@ export default class EditorModel { return new DocumentPosition(index, totalOffset - currentOffset); } + + _onAutoComplete = ({replacePart, replaceCaret, close}) => { + this._replacePart(this._autoCompletePartIdx, replacePart); + if (close) { + this._autoComplete = null; + this._autoCompletePartIdx = null; + } + } } class DocumentPosition { diff --git a/src/editor/parts.js b/src/editor/parts.js index be5326d98f..e060be716e 100644 --- a/src/editor/parts.js +++ b/src/editor/parts.js @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import AutocompleteWrapperModel from "./autocomplete"; + class BasePart { constructor(text = "") { this._text = text; @@ -39,12 +41,10 @@ class BasePart { // removes len chars, or returns the plain text this part should be replaced with // if the part would become invalid if it removed everything. - - // TODO: this should probably return the Part and caret position within this should be replaced with remove(offset, len) { // validate const strWithRemoval = this.text.substr(0, offset) + this.text.substr(offset + len); - for(let i = offset; i < (len + offset); ++i) { + for (let i = offset; i < (len + offset); ++i) { const chr = this.text.charAt(i); if (!this.acceptsRemoval(i, chr)) { return strWithRemoval; @@ -55,7 +55,7 @@ class BasePart { // append str, returns the remaining string if a character was rejected. appendUntilRejected(str) { - for(let i = 0; i < str.length; ++i) { + for (let i = 0; i < str.length; ++i) { const chr = str.charAt(i); if (!this.acceptsInsertion(chr)) { this._text = this._text + str.substr(0, i); @@ -68,7 +68,7 @@ class BasePart { // inserts str at offset if all the characters in str were accepted, otherwise don't do anything // return whether the str was accepted or not. insertAll(offset, str) { - for(let i = 0; i < str.length; ++i) { + for (let i = 0; i < str.length; ++i) { const chr = str.charAt(i); if (!this.acceptsInsertion(chr)) { return false; @@ -80,6 +80,7 @@ class BasePart { return true; } + createAutoComplete() {} trim(len) { const remaining = this._text.substr(len); @@ -94,7 +95,7 @@ class BasePart { export class PlainPart extends BasePart { acceptsInsertion(chr) { - return chr !== "@" && chr !== "#"; + return chr !== "@" && chr !== "#" && chr !== ":"; } toDOMNode() { @@ -126,6 +127,11 @@ export class PlainPart extends BasePart { } class PillPart extends BasePart { + constructor(resourceId, label) { + super(label); + this.resourceId = resourceId; + } + acceptsInsertion(chr) { return chr !== " "; } @@ -162,6 +168,10 @@ class PillPart extends BasePart { } export class RoomPillPart extends PillPart { + constructor(displayAlias) { + super(displayAlias, displayAlias); + } + get type() { return "room-pill"; } @@ -172,3 +182,51 @@ export class UserPillPart extends PillPart { return "user-pill"; } } + + +export class PillCandidatePart extends PlainPart { + constructor(text, autoCompleteCreator) { + super(text); + this._autoCompleteCreator = autoCompleteCreator; + } + + createAutoComplete(updateCallback) { + return this._autoCompleteCreator(updateCallback); + } + + acceptsInsertion(chr) { + return true; + } + + acceptsRemoval(position, chr) { + return true; + } + + get type() { + return "pill-candidate"; + } +} + +export class PartCreator { + constructor(getAutocompleterComponent, updateQuery) { + this._autoCompleteCreator = (updateCallback) => { + return new AutocompleteWrapperModel(updateCallback, getAutocompleterComponent, updateQuery); + }; + } + + createPartForInput(input) { + switch (input[0]) { + case "#": + case "@": + case ":": + return new PillCandidatePart("", this._autoCompleteCreator); + default: + return new PlainPart(); + } + } + + createDefaultPart(text) { + return new PlainPart(text); + } +} + From 317e88bef2472f25cceb8cc913c86dda87f2c681 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 15:00:48 +0200 Subject: [PATCH 13/49] initial hacky hookup of Autocomplete menu in MessageEditor --- res/css/views/elements/_MessageEditor.scss | 5 ++ .../views/elements/MessageEditor.js | 87 +++++++++++++++++-- 2 files changed, 85 insertions(+), 7 deletions(-) diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss index b3b73e88e3..487a3a0b06 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/elements/_MessageEditor.scss @@ -64,4 +64,9 @@ limitations under the License. white-space: pre; font-size: 12px; } + + .mx_MessageEditor_AutoCompleteWrapper { + position: relative; + height: 0; + } } diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index be44d4ffa8..ff0a705111 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -21,6 +21,9 @@ import dis from '../../../dispatcher'; import EditorModel from '../../../editor/model'; import {getCaretOffset, setCaretPosition} from '../../../editor/caret'; import parseEvent from '../../../editor/parse-event'; +import Autocomplete from '../rooms/Autocomplete'; +// import AutocompleteModel from '../../../editor/autocomplete'; +import {PartCreator} from '../../../editor/parts'; import {renderModel, rerenderModel} from '../../../editor/render'; import {MatrixEvent, MatrixClient} from 'matrix-js-sdk'; @@ -28,7 +31,6 @@ export default class MessageEditor extends React.Component { static propTypes = { // the latest event in this chain of replies event: PropTypes.instanceOf(MatrixEvent).isRequired, - // called when the ReplyThread contents has changed, including EventTiles thereof // onHeightChanged: PropTypes.func.isRequired, }; @@ -38,9 +40,18 @@ export default class MessageEditor extends React.Component { constructor(props, context) { super(props, context); - this.model = new EditorModel(parseEvent(this.props.event)); - this.state = {}; + const partCreator = new PartCreator( + () => this._autocompleteRef, + query => this.setState({query}), + ); + this.model = new EditorModel(parseEvent(this.props.event), partCreator); + const room = this.context.matrixClient.getRoom(this.props.event.getRoomId()); + this.state = { + autoComplete: null, + room, + }; this._editorRef = null; + this._autocompleteRef = null; } _onInput = (event) => { @@ -55,8 +66,33 @@ export default class MessageEditor extends React.Component { } setCaretPosition(this._editorRef, caret); - const modelOutput = this._editorRef.parentElement.querySelector(".model"); - modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); + this.setState({autoComplete: this.model.autoComplete}); + this._updateModelOutput(); + } + + _onKeyDown = (event) => { + if (event.metaKey || event.altKey || event.shiftKey) { + return; + } + if (!this.model.autoComplete) { + return; + } + 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(); } _onCancelClicked = () => { @@ -67,11 +103,31 @@ export default class MessageEditor extends React.Component { this._editorRef = ref; } + _collectAutocompleteRef = (ref) => { + this._autocompleteRef = ref; + } + + _onAutoCompleteConfirm = (completion) => { + this.model.autoComplete.onComponentConfirm(completion); + renderModel(this._editorRef, this.model); + this._updateModelOutput(); + } + + _onAutoCompleteSelectionChange = (completion) => { + this.model.autoComplete.onComponentSelectionChange(completion); + renderModel(this._editorRef, this.model); + this._updateModelOutput(); + } + + _updateModelOutput() { + const modelOutput = this._editorRef.parentElement.querySelector(".model"); + modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); + } + componentDidMount() { const editor = this._editorRef; rerenderModel(editor, this.model); - const modelOutput = this._editorRef.parentElement.querySelector(".model"); - modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); + this._updateModelOutput(); } render() { @@ -84,14 +140,31 @@ export default class MessageEditor extends React.Component { // } // }); // const modelOutput = JSON.stringify(this.state.parts, undefined, 2); + let autoComplete; + if (this.state.autoComplete) { + const query = this.state.query; + const queryLen = query.length; + autoComplete =
+ +
; + } const AccessibleButton = sdk.getComponent('elements.AccessibleButton'); return
+ { autoComplete }
From bb73521f0c8fb5999c421218cf90dfb7124845bf Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 15:01:17 +0200 Subject: [PATCH 14/49] prefer textContent over innerText as it's faster and transforms the text less --- src/editor/parse-event.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/editor/parse-event.js b/src/editor/parse-event.js index b4dc22ee4e..51b96a58e7 100644 --- a/src/editor/parse-event.js +++ b/src/editor/parse-event.js @@ -37,14 +37,16 @@ function parseHtmlMessage(html) { const resourceId = pillMatch[1]; // The room/user ID const prefix = pillMatch[2]; // The first character of prefix switch (prefix) { - case "@": return new UserPillPart(resourceId); - case "#": return new RoomPillPart(resourceId); - default: return new PlainPart(n.innerText); + case "@": return new UserPillPart(resourceId, n.textContent); + case "#": return new RoomPillPart(resourceId, n.textContent); + default: return new PlainPart(n.textContent); } } default: - return new PlainPart(n.innerText); + return new PlainPart(n.textContent); } + default: + return null; } }).filter(p => !!p); return parts; From 4bb8b799427f1f2a4688d382792999593b28f841 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 15:01:47 +0200 Subject: [PATCH 15/49] initial auto complete wrapper, make existing autocompleter work w/ model --- src/editor/autocomplete.js | 93 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/editor/autocomplete.js diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js new file mode 100644 index 0000000000..18b7cdee57 --- /dev/null +++ b/src/editor/autocomplete.js @@ -0,0 +1,93 @@ +/* +Copyright 2019 New Vector Ltd + +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 {UserPillPart, RoomPillPart, PlainPart} from "./parts"; + +export default class AutocompleteWrapperModel { + constructor(updateCallback, getAutocompleterComponent, updateQuery) { + this._updateCallback = updateCallback; + this._getAutocompleterComponent = getAutocompleterComponent; + this._updateQuery = updateQuery; + this._query = null; + } + + onEscape(e) { + this._getAutocompleterComponent().onEscape(e); + } + + onEnter() { + + } + + onTab() { + //forceCompletion here? + } + + onUpArrow() { + console.log("onUpArrow"); + this._getAutocompleterComponent().onUpArrow(); + } + + onDownArrow() { + console.log("onDownArrow"); + this._getAutocompleterComponent().onDownArrow(); + } + + onPartUpdate(part, offset) { + // cache the typed value and caret here + // so we can restore it in onComponentSelectionChange when the value is undefined (meaning it should be the typed text) + this._queryPart = part; + this._queryOffset = offset; + this._updateQuery(part.text); + } + + onComponentSelectionChange(completion) { + if (!completion) { + this._updateCallback({ + replacePart: this._queryPart, + replaceCaret: this._queryOffset, + }); + } else { + this._updateCallback({ + replacePart: this._partForCompletion(completion), + }); + } + } + + onComponentConfirm(completion) { + this._updateCallback({ + replacePart: this._partForCompletion(completion), + close: true, + }); + } + + _partForCompletion(completion) { + const firstChr = completion.completionId && completion.completionId[0]; + switch (firstChr) { + case "@": { + const displayName = completion.completion; + const userId = completion.completionId; + return new UserPillPart(userId, displayName); + } + case "#": { + const displayAlias = completion.completionId; + return new RoomPillPart(displayAlias); + } + default: + return new PlainPart(completion.completion); + } + } +} From fc87a27c5d25cb7c2fe12fd9694ce0cb90200c08 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 15:02:43 +0200 Subject: [PATCH 16/49] make editor nicer --- res/css/views/elements/_MessageEditor.scss | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss index 487a3a0b06..d1d06389a5 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/elements/_MessageEditor.scss @@ -24,7 +24,9 @@ limitations under the License. border: solid 1px #e9edf1; background-color: #ffffff; padding: 10px; - white-space: pre; + white-space: pre-wrap; + word-wrap: break-word; + outline: none; span { display: inline-block; From 5e6367ab5718ee8f17f0e12ee4de2f61853a6e3f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 15:42:10 +0200 Subject: [PATCH 17/49] basic support for non-editable parts e.g. pills, they get deleted when any character of them is removed later on we also shouldn't allow the caret to be set inside of them --- src/editor/model.js | 41 ++++++++++++++++++++++------------------- src/editor/parts.js | 8 ++++++++ 2 files changed, 30 insertions(+), 19 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index 2bf78026b3..6834d31c4d 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -20,7 +20,6 @@ export default class EditorModel { constructor(parts, partCreator) { this._parts = parts; this._partCreator = partCreator; - this._previousValue = parts.reduce((text, p) => text + p.text, ""); this._activePartIdx = null; this._autoComplete = null; this._autoCompletePartIdx = null; @@ -68,19 +67,19 @@ export default class EditorModel { _diff(newValue, inputType, caret) { // handle deleteContentForward (Delete key) // and deleteContentBackward (Backspace) - + const previousValue = this.parts.reduce((text, p) => text + p.text, ""); // can't use caret position with drag and drop if (inputType === "deleteByDrag") { - return diffDeletion(this._previousValue, newValue); + return diffDeletion(previousValue, newValue); } else { - return diffAtCaret(this._previousValue, newValue, caret.offset); + return diffAtCaret(previousValue, newValue, caret.offset); } } update(newValue, inputType, caret) { const diff = this._diff(newValue, inputType, caret); const position = this._positionForOffset(diff.at, caret.atNodeEnd); - console.log("update at", {position, diff, newValue, prevValue: this._previousValue}); + console.log("update at", {position, diff, newValue, prevValue: this.parts.reduce((text, p) => text + p.text, "")}); if (diff.removed) { this._removeText(position, diff.removed.length); } @@ -88,7 +87,6 @@ export default class EditorModel { this._addText(position, diff.added); } this._mergeAdjacentParts(); - this._previousValue = newValue; const caretOffset = diff.at + (diff.added ? diff.added.length : 0); const newPosition = this._positionForOffset(caretOffset, true); this._setActivePart(newPosition); @@ -141,23 +139,28 @@ export default class EditorModel { _removeText(pos, len) { let {index, offset} = pos; - while (len !== 0) { + while (len > 0) { // part might be undefined here let part = this._parts[index]; - const amount = Math.min(len, part.text.length - offset); - const replaceWith = part.remove(offset, amount); - if (typeof replaceWith === "string") { - this._replacePart(index, this._partCreator.createDefaultPart(replaceWith)); - } - part = this._parts[index]; - // remove empty part - if (!part.text.length) { - this._removePart(index); + if (part.canEdit) { + const amount = Math.min(len, part.text.length - offset); + const replaceWith = part.remove(offset, amount); + if (typeof replaceWith === "string") { + this._replacePart(index, this._partCreator.createDefaultPart(replaceWith)); + } + part = this._parts[index]; + // remove empty part + if (!part.text.length) { + this._removePart(index); + } else { + index += 1; + } + len -= amount; + offset = 0; } else { - index += 1; + len = part.length - (offset + len); + this._removePart(index); } - len -= amount; - offset = 0; } } diff --git a/src/editor/parts.js b/src/editor/parts.js index e060be716e..619bdfba9b 100644 --- a/src/editor/parts.js +++ b/src/editor/parts.js @@ -91,6 +91,10 @@ class BasePart { get text() { return this._text; } + + get canEdit() { + return true; + } } export class PlainPart extends BasePart { @@ -165,6 +169,10 @@ class PillPart extends BasePart { node.childNodes.length === 1 && node.childNodes[0].nodeType === Node.TEXT_NODE; } + + get canEdit() { + return false; + } } export class RoomPillPart extends PillPart { From aa1b4bb91e50b097392ce3bc2d957f577e926a45 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 15:43:10 +0200 Subject: [PATCH 18/49] keep auto complete code close to each other --- src/editor/model.js | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index 6834d31c4d..fb1e4801ba 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -114,6 +114,14 @@ export default class EditorModel { } } + _onAutoComplete = ({replacePart, replaceCaret, close}) => { + this._replacePart(this._autoCompletePartIdx, replacePart); + if (close) { + this._autoComplete = null; + this._autoCompletePartIdx = null; + } + } + /* updateCaret(caret) { // update active part here as well, hiding/showing autocomplete if needed @@ -202,14 +210,6 @@ export default class EditorModel { return new DocumentPosition(index, totalOffset - currentOffset); } - - _onAutoComplete = ({replacePart, replaceCaret, close}) => { - this._replacePart(this._autoCompletePartIdx, replacePart); - if (close) { - this._autoComplete = null; - this._autoCompletePartIdx = null; - } - } } class DocumentPosition { From 64b171198c87b41749d0e742d969266d1e01ec04 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 15:58:32 +0200 Subject: [PATCH 19/49] rerender through callback instead of after modifying model this way rendering is centralized and we can better rerender from interaction in the autocompleter (we didn't have access to caret before) --- .../views/elements/MessageEditor.js | 38 +++++++++---------- src/editor/model.js | 7 +++- 2 files changed, 23 insertions(+), 22 deletions(-) diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index ff0a705111..c863f8baf4 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -44,7 +44,11 @@ export default class MessageEditor extends React.Component { () => this._autocompleteRef, query => this.setState({query}), ); - this.model = new EditorModel(parseEvent(this.props.event), partCreator); + this.model = new EditorModel( + parseEvent(this.props.event), + partCreator, + this._updateEditorState, + ); const room = this.context.matrixClient.getRoom(this.props.event.getRoomId()); this.state = { autoComplete: null, @@ -54,20 +58,25 @@ export default class MessageEditor extends React.Component { this._autocompleteRef = null; } - _onInput = (event) => { - const caretOffset = getCaretOffset(this._editorRef); - const caret = this.model.update(this._editorRef.textContent, event.inputType, caretOffset); - // const parts = this.model.serializeParts(); - const shouldRerender = event.inputType === "insertFromDrop" || event.inputType === "insertFromPaste"; + _updateEditorState = (caret) => { + const shouldRerender = false; //event.inputType === "insertFromDrop" || event.inputType === "insertFromPaste"; if (shouldRerender) { rerenderModel(this._editorRef, this.model); } else { renderModel(this._editorRef, this.model); } - setCaretPosition(this._editorRef, caret); + if (caret) { + setCaretPosition(this._editorRef, caret); + } this.setState({autoComplete: this.model.autoComplete}); - this._updateModelOutput(); + const modelOutput = this._editorRef.parentElement.querySelector(".model"); + modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); + } + + _onInput = (event) => { + const caretOffset = getCaretOffset(this._editorRef); + this.model.update(this._editorRef.textContent, event.inputType, caretOffset); } _onKeyDown = (event) => { @@ -109,25 +118,14 @@ export default class MessageEditor extends React.Component { _onAutoCompleteConfirm = (completion) => { this.model.autoComplete.onComponentConfirm(completion); - renderModel(this._editorRef, this.model); - this._updateModelOutput(); } _onAutoCompleteSelectionChange = (completion) => { this.model.autoComplete.onComponentSelectionChange(completion); - renderModel(this._editorRef, this.model); - this._updateModelOutput(); - } - - _updateModelOutput() { - const modelOutput = this._editorRef.parentElement.querySelector(".model"); - modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); } componentDidMount() { - const editor = this._editorRef; - rerenderModel(editor, this.model); - this._updateModelOutput(); + this._updateEditorState(); } render() { diff --git a/src/editor/model.js b/src/editor/model.js index fb1e4801ba..999d37efca 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -17,12 +17,13 @@ limitations under the License. import {diffAtCaret, diffDeletion} from "./diff"; export default class EditorModel { - constructor(parts, partCreator) { + constructor(parts, partCreator, updateCallback) { this._parts = parts; this._partCreator = partCreator; this._activePartIdx = null; this._autoComplete = null; this._autoCompletePartIdx = null; + this._updateCallback = updateCallback; } _insertPart(index, part) { @@ -90,7 +91,7 @@ export default class EditorModel { const caretOffset = diff.at + (diff.added ? diff.added.length : 0); const newPosition = this._positionForOffset(caretOffset, true); this._setActivePart(newPosition); - return newPosition; + this._updateCallback(newPosition); } _setActivePart(pos) { @@ -116,10 +117,12 @@ export default class EditorModel { _onAutoComplete = ({replacePart, replaceCaret, close}) => { this._replacePart(this._autoCompletePartIdx, replacePart); + const index = this._autoCompletePartIdx; if (close) { this._autoComplete = null; this._autoCompletePartIdx = null; } + this._updateCallback(new DocumentPosition(index, replaceCaret)); } /* From ffff66a92d894fa6e6ce47f142467ef680be888c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 15:59:48 +0200 Subject: [PATCH 20/49] handle Escape properly close autocomplete, and also replace with plain text part. also remove leftover logging --- src/editor/autocomplete.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js index 18b7cdee57..0512a4ac49 100644 --- a/src/editor/autocomplete.js +++ b/src/editor/autocomplete.js @@ -26,6 +26,11 @@ export default class AutocompleteWrapperModel { onEscape(e) { this._getAutocompleterComponent().onEscape(e); + this._updateCallback({ + replacePart: new PlainPart(this._queryPart.text), + replaceCaret: this._queryOffset, + close: true, + }); } onEnter() { @@ -37,12 +42,10 @@ export default class AutocompleteWrapperModel { } onUpArrow() { - console.log("onUpArrow"); this._getAutocompleterComponent().onUpArrow(); } onDownArrow() { - console.log("onDownArrow"); this._getAutocompleterComponent().onDownArrow(); } From 22587da5ff12e71ca55696f200c4ca581323deb4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 16:07:00 +0200 Subject: [PATCH 21/49] close autocomplete on enter --- src/editor/autocomplete.js | 2 +- src/editor/model.js | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js index 0512a4ac49..d7ad211de2 100644 --- a/src/editor/autocomplete.js +++ b/src/editor/autocomplete.js @@ -34,7 +34,7 @@ export default class AutocompleteWrapperModel { } onEnter() { - + this._updateCallback({close: true}); } onTab() { diff --git a/src/editor/model.js b/src/editor/model.js index 999d37efca..2fa1541b99 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -116,7 +116,9 @@ export default class EditorModel { } _onAutoComplete = ({replacePart, replaceCaret, close}) => { - this._replacePart(this._autoCompletePartIdx, replacePart); + if (replacePart) { + this._replacePart(this._autoCompletePartIdx, replacePart); + } const index = this._autoCompletePartIdx; if (close) { this._autoComplete = null; From bc14d4f58f23de495fab6ca6f82d2a8e5e6d10d9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 16:07:10 +0200 Subject: [PATCH 22/49] comment --- src/editor/autocomplete.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js index d7ad211de2..a322d5e6ce 100644 --- a/src/editor/autocomplete.js +++ b/src/editor/autocomplete.js @@ -89,6 +89,7 @@ export default class AutocompleteWrapperModel { const displayAlias = completion.completionId; return new RoomPillPart(displayAlias); } + // also used for emoji completion default: return new PlainPart(completion.completion); } From 580a89875adac78f7bb21851ce129d2f1df30b68 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 16:54:58 +0200 Subject: [PATCH 23/49] fix autocompl. not always appearing/being updated when there is no part --- src/editor/model.js | 50 ++++++++++++++++++++++++++++----------------- 1 file changed, 31 insertions(+), 19 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index 2fa1541b99..ab808877a0 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -88,6 +88,7 @@ export default class EditorModel { this._addText(position, diff.added); } this._mergeAdjacentParts(); + // TODO: now that parts can be outright deleted, this doesn't make sense anymore const caretOffset = diff.at + (diff.added ? diff.added.length : 0); const newPosition = this._positionForOffset(caretOffset, true); this._setActivePart(newPosition); @@ -97,21 +98,27 @@ export default class EditorModel { _setActivePart(pos) { const {index} = pos; const part = this._parts[index]; - if (pos.index !== this._activePartIdx) { - this._activePartIdx = index; - // if there is a hidden autocomplete for this part, show it again - if (this._activePartIdx !== this._autoCompletePartIdx) { - // else try to create one - const ac = part.createAutoComplete(this._onAutoComplete); - if (ac) { - // make sure that react picks up the difference between both acs - this._autoComplete = ac; - this._autoCompletePartIdx = index; + if (part) { + if (index !== this._activePartIdx) { + this._activePartIdx = index; + if (this._activePartIdx !== this._autoCompletePartIdx) { + // else try to create one + const ac = part.createAutoComplete(this._onAutoComplete); + if (ac) { + // make sure that react picks up the difference between both acs + this._autoComplete = ac; + this._autoCompletePartIdx = index; + } } } - } - if (this._autoComplete) { - this._autoComplete.onPartUpdate(part, pos.offset); + // not _autoComplete, only there if active part is autocomplete part + if (this.autoComplete) { + this.autoComplete.onPartUpdate(part, pos.offset); + } + } else { + this._activePartIdx = null; + this._autoComplete = null; + this._autoCompletePartIdx = null; } } @@ -181,14 +188,19 @@ export default class EditorModel { let {index, offset} = pos; const part = this._parts[index]; if (part) { - if (part.insertAll(offset, str)) { - str = null; + if (part.canEdit) { + if (part.insertAll(offset, str)) { + str = null; + } else { + // console.log("splitting", offset, [part.text]); + const splitPart = part.split(offset); + // console.log("splitted", [part.text, splitPart.text]); + index += 1; + this._insertPart(index, splitPart); + } } else { - // console.log("splitting", offset, [part.text]); - const splitPart = part.split(offset); - // console.log("splitted", [part.text, splitPart.text]); + // insert str after this part index += 1; - this._insertPart(index, splitPart); } } while (str) { From 8d97c0033e770369b2179ab1334f7ee6bf1489b6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 16:55:35 +0200 Subject: [PATCH 24/49] catch this for now as caret behaviour is still a bit flaky --- src/components/views/elements/MessageEditor.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index c863f8baf4..32171391f9 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -66,9 +66,13 @@ export default class MessageEditor extends React.Component { renderModel(this._editorRef, this.model); } if (caret) { - setCaretPosition(this._editorRef, caret); + try { + setCaretPosition(this._editorRef, caret); + } catch (err) { + console.error(err); + } } - + console.log("_updateEditorState", this.state.autoComplete, this.model.autoComplete); this.setState({autoComplete: this.model.autoComplete}); const modelOutput = this._editorRef.parentElement.querySelector(".model"); modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); From 1a577eed1195c845528b14415e64775e86517009 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 19:55:24 +0200 Subject: [PATCH 25/49] take non-editable parts into account for new caret position --- src/editor/model.js | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index ab808877a0..e7284da005 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -81,15 +81,16 @@ export default class EditorModel { const diff = this._diff(newValue, inputType, caret); const position = this._positionForOffset(diff.at, caret.atNodeEnd); console.log("update at", {position, diff, newValue, prevValue: this.parts.reduce((text, p) => text + p.text, "")}); + let removedOffsetDecrease = 0; if (diff.removed) { - this._removeText(position, diff.removed.length); + removedOffsetDecrease = this._removeText(position, diff.removed.length); } + let addedLen = 0; if (diff.added) { - this._addText(position, diff.added); + addedLen = this._addText(position, diff.added); } this._mergeAdjacentParts(); - // TODO: now that parts can be outright deleted, this doesn't make sense anymore - const caretOffset = diff.at + (diff.added ? diff.added.length : 0); + const caretOffset = diff.at - removedOffsetDecrease + addedLen; const newPosition = this._positionForOffset(caretOffset, true); this._setActivePart(newPosition); this._updateCallback(newPosition); @@ -157,13 +158,19 @@ export default class EditorModel { } } + /** + * removes `len` amount of characters at `pos`. + * @return {Number} how many characters before pos were also removed, + * usually because of non-editable parts that can only be removed in their entirety. + */ _removeText(pos, len) { let {index, offset} = pos; + let removedOffsetDecrease = 0; while (len > 0) { // part might be undefined here let part = this._parts[index]; + const amount = Math.min(len, part.text.length - offset); if (part.canEdit) { - const amount = Math.min(len, part.text.length - offset); const replaceWith = part.remove(offset, amount); if (typeof replaceWith === "string") { this._replacePart(index, this._partCreator.createDefaultPart(replaceWith)); @@ -175,31 +182,38 @@ export default class EditorModel { } else { index += 1; } - len -= amount; - offset = 0; } else { - len = part.length - (offset + len); + removedOffsetDecrease += offset; this._removePart(index); } + len -= amount; + offset = 0; } + return removedOffsetDecrease; } + /** + * inserts `str` into the model at `pos`. + * @return {Number} how far from position (in characters) the insertion ended. + * This can be more than the length of `str` when crossing non-editable parts, which are skipped. + */ _addText(pos, str, actions) { - let {index, offset} = pos; + let {index} = pos; + const {offset} = pos; + let addLen = str.length; const part = this._parts[index]; if (part) { if (part.canEdit) { if (part.insertAll(offset, str)) { str = null; } else { - // console.log("splitting", offset, [part.text]); const splitPart = part.split(offset); - // console.log("splitted", [part.text, splitPart.text]); index += 1; this._insertPart(index, splitPart); } } else { - // insert str after this part + // not-editable, insert str after this part + addLen += part.text.length - offset; index += 1; } } @@ -209,6 +223,7 @@ export default class EditorModel { this._insertPart(index, newPart); index += 1; } + return addLen; } _positionForOffset(totalOffset, atPartEnd) { From 2c3453d307c3f55d805ea6025cfa55ca6db48a0f Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Thu, 9 May 2019 20:37:45 +0200 Subject: [PATCH 26/49] put caret after replaced part if no caretOffset is given by autocomplete --- src/editor/autocomplete.js | 4 ++-- src/editor/model.js | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/editor/autocomplete.js b/src/editor/autocomplete.js index a322d5e6ce..d2f73b1dff 100644 --- a/src/editor/autocomplete.js +++ b/src/editor/autocomplete.js @@ -28,7 +28,7 @@ export default class AutocompleteWrapperModel { this._getAutocompleterComponent().onEscape(e); this._updateCallback({ replacePart: new PlainPart(this._queryPart.text), - replaceCaret: this._queryOffset, + caretOffset: this._queryOffset, close: true, }); } @@ -61,7 +61,7 @@ export default class AutocompleteWrapperModel { if (!completion) { this._updateCallback({ replacePart: this._queryPart, - replaceCaret: this._queryOffset, + caretOffset: this._queryOffset, }); } else { this._updateCallback({ diff --git a/src/editor/model.js b/src/editor/model.js index e7284da005..ed350b1337 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -123,7 +123,7 @@ export default class EditorModel { } } - _onAutoComplete = ({replacePart, replaceCaret, close}) => { + _onAutoComplete = ({replacePart, caretOffset, close}) => { if (replacePart) { this._replacePart(this._autoCompletePartIdx, replacePart); } @@ -132,7 +132,10 @@ export default class EditorModel { this._autoComplete = null; this._autoCompletePartIdx = null; } - this._updateCallback(new DocumentPosition(index, replaceCaret)); + if (caretOffset === undefined) { + caretOffset = replacePart.text.length; + } + this._updateCallback(new DocumentPosition(index, caretOffset)); } /* From 7a85dd4e61f06317b7a4c40354e290d9eb60f9d3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 10 May 2019 14:24:50 +0200 Subject: [PATCH 27/49] after completion, set caret in next part at start instead of end of current part --- src/editor/model.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/editor/model.js b/src/editor/model.js index ed350b1337..fb3658a0be 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -124,18 +124,24 @@ export default class EditorModel { } _onAutoComplete = ({replacePart, caretOffset, close}) => { + let pos; if (replacePart) { this._replacePart(this._autoCompletePartIdx, replacePart); + let index = this._autoCompletePartIdx; + if (caretOffset === undefined) { + caretOffset = 0; + index += 1; + } + pos = new DocumentPosition(index, caretOffset); } - const index = this._autoCompletePartIdx; if (close) { this._autoComplete = null; this._autoCompletePartIdx = null; } - if (caretOffset === undefined) { - caretOffset = replacePart.text.length; - } - this._updateCallback(new DocumentPosition(index, caretOffset)); + // rerender even if editor contents didn't change + // to make sure the MessageEditor checks + // model.autoComplete being empty and closes it + this._updateCallback(pos); } /* From 9f597c7ec0b30c8392a91967e3b4a19e72696368 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Fri, 10 May 2019 14:25:13 +0200 Subject: [PATCH 28/49] no comment nodes without react,so can bring this back to simpler version --- src/editor/caret.js | 39 +++++++++++++-------------------------- 1 file changed, 13 insertions(+), 26 deletions(-) diff --git a/src/editor/caret.js b/src/editor/caret.js index a8fe3ddc68..f0359bed2d 100644 --- a/src/editor/caret.js +++ b/src/editor/caret.js @@ -68,35 +68,22 @@ function isVisibleNode(node) { return node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE; } -function untilVisibleNode(node) { - // need to ignore comment nodes that react uses - while (node && !isVisibleNode(node)) { - node = node.nextSibling; - } - return node; -} - export function setCaretPosition(editor, caretPosition) { - let node = untilVisibleNode(editor.firstChild); - if (!node) { - node = editor; - } else { - let {index} = caretPosition; - while (node && index) { - node = untilVisibleNode(node.nextSibling); - --index; - } - if (!node) { - node = editor; - } else if (node.nodeType === Node.ELEMENT_NODE) { - // make sure we have a text node - node = node.childNodes[0]; - } - } const sel = document.getSelection(); sel.removeAllRanges(); const range = document.createRange(); - range.setStart(node, caretPosition.offset); - range.collapse(true); + let focusNode = editor.childNodes[caretPosition.index]; + // node not found, set caret at end + if (!focusNode) { + range.selectNodeContents(editor); + range.collapse(false); + } else { + // make sure we have a text node + if (focusNode.nodeType === Node.ELEMENT_NODE) { + focusNode = focusNode.childNodes[0]; + } + range.setStart(focusNode, caretPosition.offset); + range.collapse(true); + } sel.addRange(range); } From 7ebb6ce621d5175007b0ba3ddc49dce023362ea9 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 May 2019 15:21:57 +0100 Subject: [PATCH 29/49] WIP commit, newlines sort of working --- .../views/elements/MessageEditor.js | 28 +++++- src/editor/caret.js | 81 ++++++++------- src/editor/model.js | 3 +- src/editor/parts.js | 36 ++++++- src/editor/render.js | 98 ++++++++++++++----- 5 files changed, 183 insertions(+), 63 deletions(-) diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index 32171391f9..b047e210e0 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -56,6 +56,7 @@ export default class MessageEditor extends React.Component { }; this._editorRef = null; this._autocompleteRef = null; + // document.execCommand("insertBrOnReturn", undefined, true); } _updateEditorState = (caret) => { @@ -72,15 +73,38 @@ export default class MessageEditor extends React.Component { console.error(err); } } - console.log("_updateEditorState", this.state.autoComplete, this.model.autoComplete); this.setState({autoComplete: this.model.autoComplete}); const modelOutput = this._editorRef.parentElement.querySelector(".model"); modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); } _onInput = (event) => { + console.log("finding newValue", this._editorRef.innerHTML); + let newValue = ""; + let node = this._editorRef.firstChild; + while (node && node !== this._editorRef) { + if (node.nodeType === Node.TEXT_NODE) { + newValue += node.nodeValue; + } + + if (node.firstChild) { + node = node.firstChild; + } else if (node.nextSibling) { + node = node.nextSibling; + } else { + while (!node.nextSibling && node !== this._editorRef) { + node = node.parentElement; + if (node.tagName === "DIV" && node.nextSibling && node.nextSibling.tagName === "DIV" && node !== this._editorRef) { + newValue += "\n"; + } + } + if (node !== this._editorRef) { + node = node.nextSibling; + } + } + } const caretOffset = getCaretOffset(this._editorRef); - this.model.update(this._editorRef.textContent, event.inputType, caretOffset); + this.model.update(newValue, event.inputType, caretOffset); } _onKeyDown = (event) => { diff --git a/src/editor/caret.js b/src/editor/caret.js index f0359bed2d..e9081ee05d 100644 --- a/src/editor/caret.js +++ b/src/editor/caret.js @@ -16,56 +16,67 @@ limitations under the License. export function getCaretOffset(editor) { const sel = document.getSelection(); - const atNodeEnd = sel.focusOffset === sel.focusNode.textContent.length; - let offset = sel.focusOffset; - let node = sel.focusNode; - + console.info("getCaretOffset", sel.focusNode, sel.focusOffset); // when deleting the last character of a node, // the caret gets reported as being after the focusOffset-th node, // with the focusNode being the editor - if (node === editor) { - let offset = 0; - for (let i = 0; i < sel.focusOffset; ++i) { - const node = editor.childNodes[i]; - if (isVisibleNode(node)) { - offset += node.textContent.length; - } - } - return {offset, atNodeEnd: false}; + let offset = 0; + let node; + let atNodeEnd = true; + if (sel.focusNode.nodeType === Node.TEXT_NODE) { + node = sel.focusNode; + offset = sel.focusOffset; + atNodeEnd = sel.focusOffset === sel.focusNode.textContent.length; + } else if (sel.focusNode.nodeType === Node.ELEMENT_NODE) { + node = sel.focusNode.childNodes[sel.focusOffset]; + offset = nodeLength(node); } - // first make sure we're at the level of a direct child of editor - if (node.parentElement !== editor) { - // include all preceding siblings of the non-direct editor children + while (node !== editor) { while (node.previousSibling) { node = node.previousSibling; - if (isVisibleNode(node)) { - offset += node.textContent.length; - } - } - // then move up - // I guess technically there could be preceding text nodes in the parents here as well, - // but we're assuming there are no mixed text and element nodes - while (node.parentElement !== editor) { - node = node.parentElement; - } - } - // now include the text length of all preceding direct editor children - while (node.previousSibling) { - node = node.previousSibling; - if (isVisibleNode(node)) { - offset += node.textContent.length; + offset += nodeLength(node); } + // then 1 move up + node = node.parentElement; } + + return {offset, atNodeEnd}; + + + // // first make sure we're at the level of a direct child of editor + // if (node.parentElement !== editor) { + // // include all preceding siblings of the non-direct editor children + // while (node.previousSibling) { + // node = node.previousSibling; + // offset += nodeLength(node); + // } + // // then move up + // // I guess technically there could be preceding text nodes in the parents here as well, + // // but we're assuming there are no mixed text and element nodes + // while (node.parentElement !== editor) { + // node = node.parentElement; + // } + // } + // // now include the text length of all preceding direct editor children + // while (node.previousSibling) { + // node = node.previousSibling; + // offset += nodeLength(node); + // } // { // const {focusOffset, focusNode} = sel; // console.log("selection", {focusOffset, focusNode, position, atNodeEnd}); // } - return {offset, atNodeEnd}; } -function isVisibleNode(node) { - return node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE; +function nodeLength(node) { + if (node.nodeType === Node.ELEMENT_NODE) { + const isBlock = node.tagName === "DIV"; + const isLastDiv = !node.nextSibling || node.nextSibling.tagName !== "DIV"; + return node.textContent.length + ((isBlock && !isLastDiv) ? 1 : 0); + } else { + return node.textContent.length; + } } export function setCaretPosition(editor, caretPosition) { diff --git a/src/editor/model.js b/src/editor/model.js index fb3658a0be..e7184ad3d3 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -80,7 +80,8 @@ export default class EditorModel { update(newValue, inputType, caret) { const diff = this._diff(newValue, inputType, caret); const position = this._positionForOffset(diff.at, caret.atNodeEnd); - console.log("update at", {position, diff, newValue, prevValue: this.parts.reduce((text, p) => text + p.text, "")}); + const valueWithCaret = newValue.slice(0, caret.offset) + "|" + newValue.slice(caret.offset); + console.log("update at", {diff, valueWithCaret}); let removedOffsetDecrease = 0; if (diff.removed) { removedOffsetDecrease = this._removeText(position, diff.removed.length); diff --git a/src/editor/parts.js b/src/editor/parts.js index 619bdfba9b..a20b857fee 100644 --- a/src/editor/parts.js +++ b/src/editor/parts.js @@ -95,11 +95,15 @@ class BasePart { get canEdit() { return true; } + + toString() { + return `${this.type}(${this.text})`; + } } export class PlainPart extends BasePart { acceptsInsertion(chr) { - return chr !== "@" && chr !== "#" && chr !== ":"; + return chr !== "@" && chr !== "#" && chr !== ":" && chr !== "\n"; } toDOMNode() { @@ -175,6 +179,34 @@ class PillPart extends BasePart { } } +export class NewlinePart extends BasePart { + acceptsInsertion(chr) { + return this.text.length === 0 && chr === "\n"; + } + + acceptsRemoval(position, chr) { + return true; + } + + toDOMNode() { + return document.createElement("br"); + } + + merge() { + return false; + } + + updateDOMNode() {} + + canUpdateDOMNode(node) { + return node.tagName === "BR"; + } + + get type() { + return "newline"; + } +} + export class RoomPillPart extends PillPart { constructor(displayAlias) { super(displayAlias, displayAlias); @@ -228,6 +260,8 @@ export class PartCreator { case "@": case ":": return new PillCandidatePart("", this._autoCompleteCreator); + case "\n": + return new NewlinePart(); default: return new PlainPart(); } diff --git a/src/editor/render.js b/src/editor/render.js index f7eb5d5c2b..ae39f62c41 100644 --- a/src/editor/render.js +++ b/src/editor/render.js @@ -18,35 +18,85 @@ export function rerenderModel(editor, model) { while (editor.firstChild) { editor.removeChild(editor.firstChild); } + let lineContainer = document.createElement("div"); + editor.appendChild(lineContainer); for (const part of model.parts) { - editor.appendChild(part.toDOMNode()); + if (part.type === "newline") { + lineContainer = document.createElement("div"); + editor.appendChild(lineContainer); + } else { + lineContainer.appendChild(part.toDOMNode()); + } } } export function renderModel(editor, model) { - // remove unwanted nodes, like
s - for (let i = 0; i < model.parts.length; ++i) { - const part = model.parts[i]; - let node = editor.childNodes[i]; - while (node && !part.canUpdateDOMNode(node)) { - editor.removeChild(node); - node = editor.childNodes[i]; + const lines = model.parts.reduce((lines, part) => { + if (part.type === "newline") { + lines.push([]); + } else { + const lastLine = lines[lines.length - 1]; + lastLine.push(part); } - } - for (let i = 0; i < model.parts.length; ++i) { - const part = model.parts[i]; - const node = editor.childNodes[i]; - if (node && part) { - part.updateDOMNode(node); - } else if (part) { - editor.appendChild(part.toDOMNode()); - } else if (node) { - editor.removeChild(node); + return lines; + }, [[]]); + + console.log(lines.map(parts => parts.map(p => p.toString()))); + + lines.forEach((parts, i) => { + let lineContainer = editor.childNodes[i]; + while (lineContainer && (lineContainer.tagName !== "DIV" || !!lineContainer.className)) { + editor.removeChild(lineContainer); + lineContainer = editor.childNodes[i]; } - } - let surplusElementCount = Math.max(0, editor.childNodes.length - model.parts.length); - while (surplusElementCount) { - editor.removeChild(editor.lastChild); - --surplusElementCount; - } + if (!lineContainer) { + lineContainer = document.createElement("div"); + editor.appendChild(lineContainer); + } + + if (parts.length) { + parts.forEach((part, j) => { + let partNode = lineContainer.childNodes[j]; + while (partNode && !part.canUpdateDOMNode(partNode)) { + lineContainer.removeChild(partNode); + partNode = lineContainer.childNodes[j]; + } + if (partNode && part) { + part.updateDOMNode(partNode); + } else if (part) { + lineContainer.appendChild(part.toDOMNode()); + } + }); + + let surplusElementCount = Math.max(0, lineContainer.childNodes.length - parts.length); + while (surplusElementCount) { + lineContainer.removeChild(lineContainer.lastChild); + --surplusElementCount; + } + } else { + // empty div needs to have a BR in it + let foundBR = false; + let partNode = lineContainer.firstChild; + console.log("partNode", partNode, editor.innerHTML); + while (partNode) { + console.log("partNode(in loop)", partNode); + if (!foundBR && partNode.tagName === "BR") { + foundBR = true; + } else { + lineContainer.removeChild(partNode); + } + partNode = partNode.nextSibling; + } + if (!foundBR) { + console.log("adding a BR in an empty div because there was none already"); + lineContainer.appendChild(document.createElement("br")); + } + } + + let surplusElementCount = Math.max(0, editor.childNodes.length - lines.length); + while (surplusElementCount) { + editor.removeChild(editor.lastChild); + --surplusElementCount; + } + }); } From 9e0816c51c903dede994064c212a2e7e4daf0f63 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 May 2019 16:42:00 +0100 Subject: [PATCH 30/49] find caret offset and calculate editor text in same tree-walking algo instead of having the same logic twice --- .../views/elements/MessageEditor.js | 39 ++------ src/editor/caret.js | 96 +++++-------------- src/editor/dom.js | 83 ++++++++++++++++ 3 files changed, 115 insertions(+), 103 deletions(-) create mode 100644 src/editor/dom.js diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index b047e210e0..803b06455f 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -19,7 +19,8 @@ import {_t} from '../../../languageHandler'; import PropTypes from 'prop-types'; import dis from '../../../dispatcher'; import EditorModel from '../../../editor/model'; -import {getCaretOffset, setCaretPosition} from '../../../editor/caret'; +import {setCaretPosition} from '../../../editor/caret'; +import {getCaretOffsetAndText} from '../../../editor/dom'; import parseEvent from '../../../editor/parse-event'; import Autocomplete from '../rooms/Autocomplete'; // import AutocompleteModel from '../../../editor/autocomplete'; @@ -60,15 +61,10 @@ export default class MessageEditor extends React.Component { } _updateEditorState = (caret) => { - const shouldRerender = false; //event.inputType === "insertFromDrop" || event.inputType === "insertFromPaste"; - if (shouldRerender) { - rerenderModel(this._editorRef, this.model); - } else { - renderModel(this._editorRef, this.model); - } + renderModel(this._editorRef, this.model); if (caret) { try { - setCaretPosition(this._editorRef, caret); + setCaretPosition(this._editorRef, this.model, caret); } catch (err) { console.error(err); } @@ -80,31 +76,8 @@ export default class MessageEditor extends React.Component { _onInput = (event) => { console.log("finding newValue", this._editorRef.innerHTML); - let newValue = ""; - let node = this._editorRef.firstChild; - while (node && node !== this._editorRef) { - if (node.nodeType === Node.TEXT_NODE) { - newValue += node.nodeValue; - } - - if (node.firstChild) { - node = node.firstChild; - } else if (node.nextSibling) { - node = node.nextSibling; - } else { - while (!node.nextSibling && node !== this._editorRef) { - node = node.parentElement; - if (node.tagName === "DIV" && node.nextSibling && node.nextSibling.tagName === "DIV" && node !== this._editorRef) { - newValue += "\n"; - } - } - if (node !== this._editorRef) { - node = node.nextSibling; - } - } - } - const caretOffset = getCaretOffset(this._editorRef); - this.model.update(newValue, event.inputType, caretOffset); + const {caret, text} = getCaretOffsetAndText(this._editorRef, document.getSelection()); + this.model.update(text, event.inputType, caret); } _onKeyDown = (event) => { diff --git a/src/editor/caret.js b/src/editor/caret.js index e9081ee05d..3a784aa8eb 100644 --- a/src/editor/caret.js +++ b/src/editor/caret.js @@ -14,85 +14,41 @@ See the License for the specific language governing permissions and limitations under the License. */ -export function getCaretOffset(editor) { - const sel = document.getSelection(); - console.info("getCaretOffset", sel.focusNode, sel.focusOffset); - // when deleting the last character of a node, - // the caret gets reported as being after the focusOffset-th node, - // with the focusNode being the editor - let offset = 0; - let node; - let atNodeEnd = true; - if (sel.focusNode.nodeType === Node.TEXT_NODE) { - node = sel.focusNode; - offset = sel.focusOffset; - atNodeEnd = sel.focusOffset === sel.focusNode.textContent.length; - } else if (sel.focusNode.nodeType === Node.ELEMENT_NODE) { - node = sel.focusNode.childNodes[sel.focusOffset]; - offset = nodeLength(node); - } - - while (node !== editor) { - while (node.previousSibling) { - node = node.previousSibling; - offset += nodeLength(node); - } - // then 1 move up - node = node.parentElement; - } - - return {offset, atNodeEnd}; - - - // // first make sure we're at the level of a direct child of editor - // if (node.parentElement !== editor) { - // // include all preceding siblings of the non-direct editor children - // while (node.previousSibling) { - // node = node.previousSibling; - // offset += nodeLength(node); - // } - // // then move up - // // I guess technically there could be preceding text nodes in the parents here as well, - // // but we're assuming there are no mixed text and element nodes - // while (node.parentElement !== editor) { - // node = node.parentElement; - // } - // } - // // now include the text length of all preceding direct editor children - // while (node.previousSibling) { - // node = node.previousSibling; - // offset += nodeLength(node); - // } - // { - // const {focusOffset, focusNode} = sel; - // console.log("selection", {focusOffset, focusNode, position, atNodeEnd}); - // } -} - -function nodeLength(node) { - if (node.nodeType === Node.ELEMENT_NODE) { - const isBlock = node.tagName === "DIV"; - const isLastDiv = !node.nextSibling || node.nextSibling.tagName !== "DIV"; - return node.textContent.length + ((isBlock && !isLastDiv) ? 1 : 0); - } else { - return node.textContent.length; - } -} - -export function setCaretPosition(editor, caretPosition) { +export function setCaretPosition(editor, model, caretPosition) { const sel = document.getSelection(); sel.removeAllRanges(); const range = document.createRange(); - let focusNode = editor.childNodes[caretPosition.index]; + const {parts} = model; + let lineIndex = 0; + let nodeIndex = -1; + for (let i = 0; i <= caretPosition.index; ++i) { + const part = parts[i]; + if (part && part.type === "newline") { + lineIndex += 1; + nodeIndex = -1; + } else { + nodeIndex += 1; + } + } + let focusNode; + const lineNode = editor.childNodes[lineIndex]; + if (lineNode) { + if (lineNode.childNodes.length === 0 && caretPosition.offset === 0) { + focusNode = lineNode; + } else { + focusNode = lineNode.childNodes[nodeIndex]; + + if (focusNode && focusNode.nodeType === Node.ELEMENT_NODE) { + focusNode = focusNode.childNodes[0]; + } + } + } // node not found, set caret at end if (!focusNode) { range.selectNodeContents(editor); range.collapse(false); } else { // make sure we have a text node - if (focusNode.nodeType === Node.ELEMENT_NODE) { - focusNode = focusNode.childNodes[0]; - } range.setStart(focusNode, caretPosition.offset); range.collapse(true); } diff --git a/src/editor/dom.js b/src/editor/dom.js new file mode 100644 index 0000000000..fd46c0820a --- /dev/null +++ b/src/editor/dom.js @@ -0,0 +1,83 @@ +/* +Copyright 2019 New Vector Ltd + +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. +*/ + +function walkDOMDepthFirst(editor, enterNodeCallback, leaveNodeCallback) { + let node = editor.firstChild; + while (node && node !== editor) { + enterNodeCallback(node); + if (node.firstChild) { + node = node.firstChild; + } else if (node.nextSibling) { + node = node.nextSibling; + } else { + while (!node.nextSibling && node !== editor) { + node = node.parentElement; + if (node !== editor) { + leaveNodeCallback(node); + } + } + if (node !== editor) { + node = node.nextSibling; + } + } + } +} + +export function getCaretOffsetAndText(editor, sel) { + let {focusOffset, focusNode} = sel; + let caretOffset = focusOffset; + let foundCaret = false; + let text = ""; + + if (focusNode.nodeType === Node.ELEMENT_NODE && focusOffset !== 0) { + focusNode = focusNode.childNodes[focusOffset - 1]; + caretOffset = focusNode.textContent.length; + } + + function enterNodeCallback(node) { + const nodeText = node.nodeType === Node.TEXT_NODE && node.nodeValue; + if (!foundCaret) { + if (node === focusNode) { + foundCaret = true; + } + } + if (nodeText) { + if (!foundCaret) { + caretOffset += nodeText.length; + } + text += nodeText; + } + } + + function leaveNodeCallback(node) { + // if this is not the last DIV (which are only used as line containers atm) + // we don't just check if there is a nextSibling because sometimes the caret ends up + // after the last DIV and it creates a newline if you type then, + // whereas you just want it to be appended to the current line + if (node.tagName === "DIV" && node.nextSibling && node.nextSibling.tagName === "DIV") { + text += "\n"; + if (!foundCaret) { + caretOffset += 1; + } + } + } + + walkDOMDepthFirst(editor, enterNodeCallback, leaveNodeCallback); + + const atNodeEnd = sel.focusOffset === sel.focusNode.textContent.length; + const caret = {atNodeEnd, offset: caretOffset}; + return {caret, text}; +} From 4ff37ca046d8371b048744d3339e872d5c7acbb3 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 May 2019 17:45:35 +0100 Subject: [PATCH 31/49] don't show model for now --- res/css/views/elements/_MessageEditor.scss | 2 +- src/components/views/elements/MessageEditor.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss index d1d06389a5..9d6fa6d064 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/elements/_MessageEditor.scss @@ -62,7 +62,7 @@ limitations under the License. .model { background: lightgrey; padding: 5px; - display: block; + display: none; white-space: pre; font-size: 12px; } diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index 803b06455f..a0da298f06 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -70,8 +70,8 @@ export default class MessageEditor extends React.Component { } } this.setState({autoComplete: this.model.autoComplete}); - const modelOutput = this._editorRef.parentElement.querySelector(".model"); - modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); + // const modelOutput = this._editorRef.parentElement.querySelector(".model"); + // modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); } _onInput = (event) => { From a3b02cf0cc4e4f5db5df8f58c1f7c6b71096854c Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 May 2019 17:45:54 +0100 Subject: [PATCH 32/49] make logging quiet --- src/components/views/elements/MessageEditor.js | 6 +++--- src/editor/model.js | 5 +++-- src/editor/render.js | 5 +---- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index a0da298f06..bc5cd021dd 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -57,7 +57,6 @@ export default class MessageEditor extends React.Component { }; this._editorRef = null; this._autocompleteRef = null; - // document.execCommand("insertBrOnReturn", undefined, true); } _updateEditorState = (caret) => { @@ -75,8 +74,9 @@ export default class MessageEditor extends React.Component { } _onInput = (event) => { - console.log("finding newValue", this._editorRef.innerHTML); - const {caret, text} = getCaretOffsetAndText(this._editorRef, document.getSelection()); + const sel = document.getSelection(); + // console.log("finding newValue", this._editorRef.innerHTML, sel); + const {caret, text} = getCaretOffsetAndText(this._editorRef, sel); this.model.update(text, event.inputType, caret); } diff --git a/src/editor/model.js b/src/editor/model.js index e7184ad3d3..c35d55e309 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -80,8 +80,8 @@ export default class EditorModel { update(newValue, inputType, caret) { const diff = this._diff(newValue, inputType, caret); const position = this._positionForOffset(diff.at, caret.atNodeEnd); - const valueWithCaret = newValue.slice(0, caret.offset) + "|" + newValue.slice(caret.offset); - console.log("update at", {diff, valueWithCaret}); + // const valueWithCaret = newValue.slice(0, caret.offset) + "|" + newValue.slice(caret.offset); + // console.log("update at", {diff, valueWithCaret}); let removedOffsetDecrease = 0; if (diff.removed) { removedOffsetDecrease = this._removeText(position, diff.removed.length); @@ -93,6 +93,7 @@ export default class EditorModel { this._mergeAdjacentParts(); const caretOffset = diff.at - removedOffsetDecrease + addedLen; const newPosition = this._positionForOffset(caretOffset, true); + // console.log("caretOffset", {at: diff.at, removedOffsetDecrease, addedLen}, newPosition); this._setActivePart(newPosition); this._updateCallback(newPosition); } diff --git a/src/editor/render.js b/src/editor/render.js index ae39f62c41..bb04a4babd 100644 --- a/src/editor/render.js +++ b/src/editor/render.js @@ -74,12 +74,10 @@ export function renderModel(editor, model) { --surplusElementCount; } } else { - // empty div needs to have a BR in it + // empty div needs to have a BR in it to give it height let foundBR = false; let partNode = lineContainer.firstChild; - console.log("partNode", partNode, editor.innerHTML); while (partNode) { - console.log("partNode(in loop)", partNode); if (!foundBR && partNode.tagName === "BR") { foundBR = true; } else { @@ -88,7 +86,6 @@ export function renderModel(editor, model) { partNode = partNode.nextSibling; } if (!foundBR) { - console.log("adding a BR in an empty div because there was none already"); lineContainer.appendChild(document.createElement("br")); } } From c44fed4bea0bef0e5fe12bd5f0d0ff2dbfc3fce7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 May 2019 17:51:20 +0100 Subject: [PATCH 33/49] even less logging --- src/editor/render.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/editor/render.js b/src/editor/render.js index bb04a4babd..34643ff8b7 100644 --- a/src/editor/render.js +++ b/src/editor/render.js @@ -41,7 +41,7 @@ export function renderModel(editor, model) { return lines; }, [[]]); - console.log(lines.map(parts => parts.map(p => p.toString()))); + // console.log(lines.map(parts => parts.map(p => p.toString()))); lines.forEach((parts, i) => { let lineContainer = editor.childNodes[i]; From c98e716cbda12498b023acc63db22cfbf2749bab Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 May 2019 17:56:30 +0100 Subject: [PATCH 34/49] some pill styling --- res/css/views/elements/_MessageEditor.scss | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss index 9d6fa6d064..da1dbb23cb 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/elements/_MessageEditor.scss @@ -35,12 +35,13 @@ limitations under the License. color: white; } - span.user-pill { - background: red; - } - - span.room-pill { - background: green; + span.user-pill, span.room-pill { + border-radius: 16px; + display: inline-block; + color: $primary-fg-color; + background-color: $other-user-pill-bg-color; + padding-left: 5px; + padding-right: 5px; } } From eaf43d7277a5239d53806a4155205b6ff43aa990 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 May 2019 18:20:21 +0100 Subject: [PATCH 35/49] correctly parse BRs --- src/editor/parse-event.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/editor/parse-event.js b/src/editor/parse-event.js index 51b96a58e7..455c51bcf4 100644 --- a/src/editor/parse-event.js +++ b/src/editor/parse-event.js @@ -15,7 +15,7 @@ limitations under the License. */ import { MATRIXTO_URL_PATTERN } from '../linkify-matrix'; -import { PlainPart, UserPillPart, RoomPillPart } from "./parts"; +import { PlainPart, UserPillPart, RoomPillPart, NewlinePart } from "./parts"; function parseHtmlMessage(html) { const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN); @@ -42,6 +42,8 @@ function parseHtmlMessage(html) { default: return new PlainPart(n.textContent); } } + case "BR": + return new NewlinePart("\n"); default: return new PlainPart(n.textContent); } From 2fbe73e6581e6bc92a40156268efd4cde9ef3031 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Mon, 13 May 2019 18:20:32 +0100 Subject: [PATCH 36/49] draft of formatting --- src/editor/html_serialize.js | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 src/editor/html_serialize.js diff --git a/src/editor/html_serialize.js b/src/editor/html_serialize.js new file mode 100644 index 0000000000..bd8842b01f --- /dev/null +++ b/src/editor/html_serialize.js @@ -0,0 +1,14 @@ +export function htmlSerialize(model) { + return model.parts.reduce((html, part) => { + switch (part.type) { + case "newline": + return html + "
"; + case "plain": + case "pill-candidate": + return html + part.text; + case "room-pill": + case "user-pill": + return html + `${part.text}`; + } + }, ""); +} From 3abdf6b1001f79a15cf924310cc4fdd90aeec6de Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 10:37:16 +0100 Subject: [PATCH 37/49] also serialize to text and method to tell us if we need html for model --- src/editor/html_serialize.js | 14 ------------ src/editor/serialize.js | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 14 deletions(-) delete mode 100644 src/editor/html_serialize.js create mode 100644 src/editor/serialize.js diff --git a/src/editor/html_serialize.js b/src/editor/html_serialize.js deleted file mode 100644 index bd8842b01f..0000000000 --- a/src/editor/html_serialize.js +++ /dev/null @@ -1,14 +0,0 @@ -export function htmlSerialize(model) { - return model.parts.reduce((html, part) => { - switch (part.type) { - case "newline": - return html + "
"; - case "plain": - case "pill-candidate": - return html + part.text; - case "room-pill": - case "user-pill": - return html + `${part.text}`; - } - }, ""); -} diff --git a/src/editor/serialize.js b/src/editor/serialize.js new file mode 100644 index 0000000000..57cc79b375 --- /dev/null +++ b/src/editor/serialize.js @@ -0,0 +1,43 @@ +export function htmlSerialize(model) { + return model.parts.reduce((html, part) => { + switch (part.type) { + case "newline": + return html + "
"; + case "plain": + case "pill-candidate": + return html + part.text; + case "room-pill": + case "user-pill": + return html + `${part.text}`; + } + }, ""); +} + +export function textSerialize(model) { + return model.parts.reduce((text, part) => { + switch (part.type) { + case "newline": + return text + "\n"; + case "plain": + case "pill-candidate": + return text + part.text; + case "room-pill": + case "user-pill": + return text + `${part.resourceId}`; + } + }, ""); +} + +export function requiresHtml(model) { + return model.parts.some(part => { + switch (part.type) { + case "newline": + case "plain": + case "pill-candidate": + return false; + case "room-pill": + case "user-pill": + return true; + } + }); +} From 34dbe5f314884fc34308ac6f3c687859e5cb6113 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 10:37:40 +0100 Subject: [PATCH 38/49] add newline parts for text messages as well --- src/editor/parse-event.js | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/editor/parse-event.js b/src/editor/parse-event.js index 455c51bcf4..303f234f9e 100644 --- a/src/editor/parse-event.js +++ b/src/editor/parse-event.js @@ -59,6 +59,17 @@ export default function parseEvent(event) { if (content.format === "org.matrix.custom.html") { return parseHtmlMessage(content.formatted_body); } else { - return [new PlainPart(content.body)]; + const lines = content.body.split("\n"); + const parts = lines.reduce((parts, line, i) => { + const isLast = i === lines.length - 1; + const text = new PlainPart(line); + const newLine = !isLast && new NewlinePart("\n"); + if (newLine) { + return parts.concat(text, newLine); + } else { + return parts.concat(text); + } + }, []); + return parts; } } From 759a4a54efc75ee0834de1d5d51b07a03ac5438a Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 10:37:57 +0100 Subject: [PATCH 39/49] send the actual m.replace event from composer content --- res/css/views/elements/_MessageEditor.scss | 9 ++--- .../views/elements/MessageEditor.js | 39 ++++++++++++------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss index da1dbb23cb..2829413a27 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/elements/_MessageEditor.scss @@ -47,16 +47,13 @@ limitations under the License. .buttons { display: flex; - flex-direction: column; - align-items: end; + flex-direction: row; + justify-content: end; padding: 5px 0; .mx_AccessibleButton { - background-color: $button-bg-color; - border-radius: 4px; + margin-left: 5px; padding: 5px 40px; - color: $button-fg-color; - font-weight: 600; } } diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index bc5cd021dd..62f4d442a7 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -21,11 +21,12 @@ import dis from '../../../dispatcher'; import EditorModel from '../../../editor/model'; import {setCaretPosition} from '../../../editor/caret'; import {getCaretOffsetAndText} from '../../../editor/dom'; +import {htmlSerialize, textSerialize, requiresHtml} from '../../../editor/serialize'; import parseEvent from '../../../editor/parse-event'; import Autocomplete from '../rooms/Autocomplete'; // import AutocompleteModel from '../../../editor/autocomplete'; import {PartCreator} from '../../../editor/parts'; -import {renderModel, rerenderModel} from '../../../editor/render'; +import {renderModel} from '../../../editor/render'; import {MatrixEvent, MatrixClient} from 'matrix-js-sdk'; export default class MessageEditor extends React.Component { @@ -109,6 +110,26 @@ export default class MessageEditor extends React.Component { dis.dispatch({action: "edit_event", event: null}); } + _onSaveClicked = () => { + const content = { + "msgtype": "m.text", + "body": textSerialize(this.model), + "m.relates_to": { + "rel_type": "m.replace", + "event_id": this.props.event.getId(), + }, + }; + if (requiresHtml(this.model)) { + content.format = "org.matrix.custom.html"; + content.formatted_body = htmlSerialize(this.model); + } + + const roomId = this.props.event.getRoomId(); + this.context.matrixClient.sendMessage(roomId, content); + + dis.dispatch({action: "edit_event", event: null}); + } + _collectEditorRef = (ref) => { this._editorRef = ref; } @@ -130,15 +151,6 @@ export default class MessageEditor extends React.Component { } render() { - // const parts = this.state.parts.map((p, i) => { - // const key = `${i}-${p.type}`; - // switch (p.type) { - // case "plain": return p.text; - // case "room-pill": return ({p.text}); - // case "user-pill": return ({p.text}); - // } - // }); - // const modelOutput = JSON.stringify(this.state.parts, undefined, 2); let autoComplete; if (this.state.autoComplete) { const query = this.state.query; @@ -161,14 +173,13 @@ export default class MessageEditor extends React.Component { className="editor" contentEditable="true" tabIndex="1" - // suppressContentEditableWarning={true} onInput={this._onInput} onKeyDown={this._onKeyDown} ref={this._collectEditorRef} - > -
+ >
- {_t("Cancel")} + {_t("Cancel")} + {_t("Save")}
; From 036cb02c0eab79dd73044e2073b7be1a6a2fafc7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 10:51:04 +0100 Subject: [PATCH 40/49] add feature flag --- src/components/views/messages/MessageActionBar.js | 14 ++++++++++---- src/i18n/strings/en_EN.json | 3 ++- src/settings/Settings.js | 6 ++++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/components/views/messages/MessageActionBar.js b/src/components/views/messages/MessageActionBar.js index c4b8c441bd..fe6c22ab1e 100644 --- a/src/components/views/messages/MessageActionBar.js +++ b/src/components/views/messages/MessageActionBar.js @@ -103,6 +103,10 @@ export default class MessageActionBar extends React.PureComponent { return SettingsStore.isFeatureEnabled("feature_reactions"); } + isEditingEnabled() { + return SettingsStore.isFeatureEnabled("feature_message_editing"); + } + renderAgreeDimension() { if (!this.isReactionsEnabled()) { return null; @@ -144,10 +148,12 @@ export default class MessageActionBar extends React.PureComponent { title={_t("Reply")} onClick={this.onReplyClick} />; - editButton = ; + if (this.isEditingEnabled()) { + editButton = ; + } } return
diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index e407d92630..1d524cbcbc 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -300,6 +300,7 @@ "Show recent room avatars above the room list": "Show recent room avatars above the room list", "Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)", "Render simple counters in room header": "Render simple counters in room header", + "Edit messages after they have been sent": "Edit messages after they have been sent", "React to messages with emoji (refresh to apply changes)": "React to messages with emoji (refresh to apply changes)", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", "Use compact timeline layout": "Use compact timeline layout", @@ -897,6 +898,7 @@ "Agree or Disagree": "Agree or Disagree", "Like or Dislike": "Like or Dislike", "Reply": "Reply", + "Edit": "Edit", "Options": "Options", "Attachment": "Attachment", "Error decrypting attachment": "Error decrypting attachment", @@ -973,7 +975,6 @@ "Reload widget": "Reload widget", "Popout widget": "Popout widget", "Picture": "Picture", - "Edit": "Edit", "Revoke widget access": "Revoke widget access", "Create new room": "Create new room", "Unblacklist": "Unblacklist", diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 1c3ca4fd0f..76d220cf56 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -118,6 +118,12 @@ export const SETTINGS = { supportedLevels: LEVELS_FEATURE, default: false, }, + "feature_message_editing": { + isFeature: true, + displayName: _td("Edit messages after they have been sent"), + supportedLevels: LEVELS_FEATURE, + default: false, + }, "feature_reactions": { isFeature: true, displayName: _td("React to messages with emoji (refresh to apply changes)"), From e2388afb51c2e3f509268204a0426dabe3376fa7 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 10:53:42 +0100 Subject: [PATCH 41/49] consistent naming between serialize and deserialize modules --- src/components/views/elements/MessageEditor.js | 2 +- src/editor/{parse-event.js => deserialize.js} | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/editor/{parse-event.js => deserialize.js} (98%) diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index 62f4d442a7..db0ddb74b2 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -22,7 +22,7 @@ import EditorModel from '../../../editor/model'; import {setCaretPosition} from '../../../editor/caret'; import {getCaretOffsetAndText} from '../../../editor/dom'; import {htmlSerialize, textSerialize, requiresHtml} from '../../../editor/serialize'; -import parseEvent from '../../../editor/parse-event'; +import {parseEvent} from '../../../editor/deserialize'; import Autocomplete from '../rooms/Autocomplete'; // import AutocompleteModel from '../../../editor/autocomplete'; import {PartCreator} from '../../../editor/parts'; diff --git a/src/editor/parse-event.js b/src/editor/deserialize.js similarity index 98% rename from src/editor/parse-event.js rename to src/editor/deserialize.js index 303f234f9e..969393b4d9 100644 --- a/src/editor/parse-event.js +++ b/src/editor/deserialize.js @@ -54,7 +54,7 @@ function parseHtmlMessage(html) { return parts; } -export default function parseEvent(event) { +export function parseEvent(event) { const content = event.getContent(); if (content.format === "org.matrix.custom.html") { return parseHtmlMessage(content.formatted_body); From 15df72e62921c2ab75d19cbaaca6c266d891b3cf Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 15:20:34 +0100 Subject: [PATCH 42/49] reload events when event gets replaced in the timeline --- src/components/structures/TimelinePanel.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/components/structures/TimelinePanel.js b/src/components/structures/TimelinePanel.js index 6529e92256..350dcd72c3 100644 --- a/src/components/structures/TimelinePanel.js +++ b/src/components/structures/TimelinePanel.js @@ -204,6 +204,7 @@ const TimelinePanel = React.createClass({ MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline); MatrixClientPeg.get().on("Room.timelineReset", this.onRoomTimelineReset); MatrixClientPeg.get().on("Room.redaction", this.onRoomRedaction); + MatrixClientPeg.get().on("Room.replaceEvent", this.onRoomReplaceEvent); MatrixClientPeg.get().on("Room.receipt", this.onRoomReceipt); MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated); MatrixClientPeg.get().on("Room.accountData", this.onAccountData); @@ -282,6 +283,7 @@ const TimelinePanel = React.createClass({ client.removeListener("Room.timeline", this.onRoomTimeline); client.removeListener("Room.timelineReset", this.onRoomTimelineReset); client.removeListener("Room.redaction", this.onRoomRedaction); + client.removeListener("Room.replaceEvent", this.onRoomReplaceEvent); client.removeListener("Room.receipt", this.onRoomReceipt); client.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated); client.removeListener("Room.accountData", this.onAccountData); @@ -505,6 +507,17 @@ const TimelinePanel = React.createClass({ this.forceUpdate(); }, + onRoomReplaceEvent: function(replacedEvent, newEvent, room) { + if (this.unmounted) return; + + // ignore events for other rooms + if (room !== this.props.timelineSet.room) return; + + // we could skip an update if the event isn't in our timeline, + // but that's probably an early optimisation. + this._reloadEvents(); + }, + onRoomReceipt: function(ev, room) { if (this.unmounted) return; From 45991bc3de58cad3cc89b20541068fe1fc52e4c5 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 15:20:52 +0100 Subject: [PATCH 43/49] replace original event if there have been previous edits --- src/components/views/elements/MessageEditor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index db0ddb74b2..13febbf5cc 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -116,7 +116,7 @@ export default class MessageEditor extends React.Component { "body": textSerialize(this.model), "m.relates_to": { "rel_type": "m.replace", - "event_id": this.props.event.getId(), + "event_id": this.props.event.getOriginalId(), }, }; if (requiresHtml(this.model)) { From 0b18ff52c528b343a248b595cd00f5e21c883417 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 15:41:55 +0100 Subject: [PATCH 44/49] pass feature flag to js-sdk --- src/MatrixClientPeg.js | 2 ++ src/i18n/strings/en_EN.json | 2 +- src/settings/Settings.js | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/MatrixClientPeg.js b/src/MatrixClientPeg.js index cd40c7874e..8796d7fe30 100644 --- a/src/MatrixClientPeg.js +++ b/src/MatrixClientPeg.js @@ -176,6 +176,7 @@ class MatrixClientPeg { _createClient(creds: MatrixClientCreds) { const aggregateRelations = SettingsStore.isFeatureEnabled("feature_reactions"); + const enableEdits = SettingsStore.isFeatureEnabled("feature_message_editing"); const opts = { baseUrl: creds.homeserverUrl, @@ -187,6 +188,7 @@ class MatrixClientPeg { forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false), verificationMethods: [verificationMethods.SAS], unstableClientRelationAggregation: aggregateRelations, + unstableClientRelationReplacements: enableEdits, }; this.matrixClient = createMatrixClient(opts); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 1d524cbcbc..393184a6c4 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -300,7 +300,7 @@ "Show recent room avatars above the room list": "Show recent room avatars above the room list", "Group & filter rooms by custom tags (refresh to apply changes)": "Group & filter rooms by custom tags (refresh to apply changes)", "Render simple counters in room header": "Render simple counters in room header", - "Edit messages after they have been sent": "Edit messages after they have been sent", + "Edit messages after they have been sent (refresh to apply changes)": "Edit messages after they have been sent (refresh to apply changes)", "React to messages with emoji (refresh to apply changes)": "React to messages with emoji (refresh to apply changes)", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", "Use compact timeline layout": "Use compact timeline layout", diff --git a/src/settings/Settings.js b/src/settings/Settings.js index 76d220cf56..429030d862 100644 --- a/src/settings/Settings.js +++ b/src/settings/Settings.js @@ -120,7 +120,7 @@ export const SETTINGS = { }, "feature_message_editing": { isFeature: true, - displayName: _td("Edit messages after they have been sent"), + displayName: _td("Edit messages after they have been sent (refresh to apply changes)"), supportedLevels: LEVELS_FEATURE, default: false, }, From fd31e793d120ad0baa83c934506bac61b3c8749e Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 15:49:53 +0100 Subject: [PATCH 45/49] fix lint --- src/editor/dom.js | 3 ++- src/editor/model.js | 6 +++++- src/editor/render.js | 4 +--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/editor/dom.js b/src/editor/dom.js index fd46c0820a..0899fd25b3 100644 --- a/src/editor/dom.js +++ b/src/editor/dom.js @@ -37,7 +37,8 @@ function walkDOMDepthFirst(editor, enterNodeCallback, leaveNodeCallback) { } export function getCaretOffsetAndText(editor, sel) { - let {focusOffset, focusNode} = sel; + let {focusNode} = sel; + const {focusOffset} = sel; let caretOffset = focusOffset; let foundCaret = false; let text = ""; diff --git a/src/editor/model.js b/src/editor/model.js index c35d55e309..ebf92c9a79 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -171,6 +171,8 @@ export default class EditorModel { /** * removes `len` amount of characters at `pos`. + * @param {Object} pos + * @param {Number} len * @return {Number} how many characters before pos were also removed, * usually because of non-editable parts that can only be removed in their entirety. */ @@ -205,10 +207,12 @@ export default class EditorModel { /** * inserts `str` into the model at `pos`. + * @param {Object} pos + * @param {string} str * @return {Number} how far from position (in characters) the insertion ended. * This can be more than the length of `str` when crossing non-editable parts, which are skipped. */ - _addText(pos, str, actions) { + _addText(pos, str) { let {index} = pos; const {offset} = pos; let addLen = str.length; diff --git a/src/editor/render.js b/src/editor/render.js index 34643ff8b7..052c11c320 100644 --- a/src/editor/render.js +++ b/src/editor/render.js @@ -40,9 +40,7 @@ export function renderModel(editor, model) { } return lines; }, [[]]); - - // console.log(lines.map(parts => parts.map(p => p.toString()))); - + // TODO: refactor this code, DRY it lines.forEach((parts, i) => { let lineContainer = editor.childNodes[i]; while (lineContainer && (lineContainer.tagName !== "DIV" || !!lineContainer.className)) { From dc21faa2403714b336e07a6a6a648939ab1e9240 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Tue, 14 May 2019 16:32:08 +0100 Subject: [PATCH 46/49] send edit also in n.new_content field so we can have fallback content in the regular content for clients that don't support edits. Note that we're not reading m.new_content yet as it's going to be a bit of a headache to change this. So for now just sending the edit in both the normal content and the m.new_content subfield, so all events out there already are well-formed --- src/components/views/elements/MessageEditor.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index 13febbf5cc..f44abd87e9 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -111,18 +111,21 @@ export default class MessageEditor extends React.Component { } _onSaveClicked = () => { - const content = { + const newContent = { "msgtype": "m.text", "body": textSerialize(this.model), + }; + if (requiresHtml(this.model)) { + newContent.format = "org.matrix.custom.html"; + newContent.formatted_body = htmlSerialize(this.model); + } + const content = Object.assign({ + "m.new_content": newContent, "m.relates_to": { "rel_type": "m.replace", "event_id": this.props.event.getOriginalId(), }, - }; - if (requiresHtml(this.model)) { - content.format = "org.matrix.custom.html"; - content.formatted_body = htmlSerialize(this.model); - } + }, newContent); const roomId = this.props.event.getRoomId(); this.context.matrixClient.sendMessage(roomId, content); From d83e278f6b357ba49e05de82ead3632d8cd092a4 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 15 May 2019 09:46:08 +0100 Subject: [PATCH 47/49] PR feedback, cleanup --- res/css/views/elements/_MessageEditor.scss | 14 +++-------- .../views/elements/MessageEditor.js | 24 ++++--------------- src/editor/model.js | 11 --------- src/editor/render.js | 16 ------------- 4 files changed, 8 insertions(+), 57 deletions(-) diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss index 2829413a27..b58e18c19b 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/elements/_MessageEditor.scss @@ -1,5 +1,5 @@ /* -Copyright 2019 Vector Creations Ltd +Copyright 2019 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -19,7 +19,7 @@ limitations under the License. background-color: #f3f8fd; padding: 11px 13px 7px 56px; - .editor { + .mx_MessageEditor_editor { border-radius: 4px; border: solid 1px #e9edf1; background-color: #ffffff; @@ -45,7 +45,7 @@ limitations under the License. } } - .buttons { + .mx_MessageEditor_buttons { display: flex; flex-direction: row; justify-content: end; @@ -57,14 +57,6 @@ limitations under the License. } } - .model { - background: lightgrey; - padding: 5px; - display: none; - white-space: pre; - font-size: 12px; - } - .mx_MessageEditor_AutoCompleteWrapper { position: relative; height: 0; diff --git a/src/components/views/elements/MessageEditor.js b/src/components/views/elements/MessageEditor.js index f44abd87e9..f8d08b313f 100644 --- a/src/components/views/elements/MessageEditor.js +++ b/src/components/views/elements/MessageEditor.js @@ -24,16 +24,14 @@ import {getCaretOffsetAndText} from '../../../editor/dom'; import {htmlSerialize, textSerialize, requiresHtml} from '../../../editor/serialize'; import {parseEvent} from '../../../editor/deserialize'; import Autocomplete from '../rooms/Autocomplete'; -// import AutocompleteModel from '../../../editor/autocomplete'; import {PartCreator} from '../../../editor/parts'; import {renderModel} from '../../../editor/render'; import {MatrixEvent, MatrixClient} from 'matrix-js-sdk'; export default class MessageEditor extends React.Component { static propTypes = { - // the latest event in this chain of replies + // the message event being edited event: PropTypes.instanceOf(MatrixEvent).isRequired, - // onHeightChanged: PropTypes.func.isRequired, }; static contextTypes = { @@ -70,13 +68,10 @@ export default class MessageEditor extends React.Component { } } this.setState({autoComplete: this.model.autoComplete}); - // const modelOutput = this._editorRef.parentElement.querySelector(".model"); - // modelOutput.textContent = JSON.stringify(this.model.serializeParts(), undefined, 2); } _onInput = (event) => { const sel = document.getSelection(); - // console.log("finding newValue", this._editorRef.innerHTML, sel); const {caret, text} = getCaretOffsetAndText(this._editorRef, sel); this.model.update(text, event.inputType, caret); } @@ -133,14 +128,6 @@ export default class MessageEditor extends React.Component { dis.dispatch({action: "edit_event", event: null}); } - _collectEditorRef = (ref) => { - this._editorRef = ref; - } - - _collectAutocompleteRef = (ref) => { - this._autocompleteRef = ref; - } - _onAutoCompleteConfirm = (completion) => { this.model.autoComplete.onComponentConfirm(completion); } @@ -160,7 +147,7 @@ export default class MessageEditor extends React.Component { const queryLen = query.length; autoComplete =
this._autocompleteRef = ref} query={query} onConfirm={this._onAutoCompleteConfirm} onSelectionChange={this._onAutoCompleteSelectionChange} @@ -173,18 +160,17 @@ export default class MessageEditor extends React.Component { return
{ autoComplete }
this._editorRef = ref} >
-
+
{_t("Cancel")} {_t("Save")}
-
; } } diff --git a/src/editor/model.js b/src/editor/model.js index ebf92c9a79..85dd425b0e 100644 --- a/src/editor/model.js +++ b/src/editor/model.js @@ -66,8 +66,6 @@ export default class EditorModel { } _diff(newValue, inputType, caret) { - // handle deleteContentForward (Delete key) - // and deleteContentBackward (Backspace) const previousValue = this.parts.reduce((text, p) => text + p.text, ""); // can't use caret position with drag and drop if (inputType === "deleteByDrag") { @@ -80,8 +78,6 @@ export default class EditorModel { update(newValue, inputType, caret) { const diff = this._diff(newValue, inputType, caret); const position = this._positionForOffset(diff.at, caret.atNodeEnd); - // const valueWithCaret = newValue.slice(0, caret.offset) + "|" + newValue.slice(caret.offset); - // console.log("update at", {diff, valueWithCaret}); let removedOffsetDecrease = 0; if (diff.removed) { removedOffsetDecrease = this._removeText(position, diff.removed.length); @@ -93,7 +89,6 @@ export default class EditorModel { this._mergeAdjacentParts(); const caretOffset = diff.at - removedOffsetDecrease + addedLen; const newPosition = this._positionForOffset(caretOffset, true); - // console.log("caretOffset", {at: diff.at, removedOffsetDecrease, addedLen}, newPosition); this._setActivePart(newPosition); this._updateCallback(newPosition); } @@ -146,12 +141,6 @@ export default class EditorModel { this._updateCallback(pos); } - /* - updateCaret(caret) { - // update active part here as well, hiding/showing autocomplete if needed - } - */ - _mergeAdjacentParts(docPos) { let prevPart = this._parts[0]; for (let i = 1; i < this._parts.length; ++i) { diff --git a/src/editor/render.js b/src/editor/render.js index 052c11c320..abc5d42fa1 100644 --- a/src/editor/render.js +++ b/src/editor/render.js @@ -14,22 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -export function rerenderModel(editor, model) { - while (editor.firstChild) { - editor.removeChild(editor.firstChild); - } - let lineContainer = document.createElement("div"); - editor.appendChild(lineContainer); - for (const part of model.parts) { - if (part.type === "newline") { - lineContainer = document.createElement("div"); - editor.appendChild(lineContainer); - } else { - lineContainer.appendChild(part.toDOMNode()); - } - } -} - export function renderModel(editor, model) { const lines = model.parts.reduce((lines, part) => { if (part.type === "newline") { From 6b932d58e6acb6311d8958989cdf9bc2f63c71a0 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 15 May 2019 10:10:29 +0100 Subject: [PATCH 48/49] remove cruft from edit icon --- res/img/edit.svg | 98 +----------------------------------------------- 1 file changed, 1 insertion(+), 97 deletions(-) diff --git a/res/img/edit.svg b/res/img/edit.svg index 15b5ef9563..95bd44f606 100644 --- a/res/img/edit.svg +++ b/res/img/edit.svg @@ -1,97 +1 @@ - - - - - - image/svg+xml - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file From 22533ba2d4fb5e3e26568126b04c02f41c4097b6 Mon Sep 17 00:00:00 2001 From: Bruno Windels Date: Wed, 15 May 2019 10:12:16 +0100 Subject: [PATCH 49/49] use theme var for bg color --- res/css/views/elements/_MessageEditor.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/res/css/views/elements/_MessageEditor.scss b/res/css/views/elements/_MessageEditor.scss index b58e18c19b..ec6d903753 100644 --- a/res/css/views/elements/_MessageEditor.scss +++ b/res/css/views/elements/_MessageEditor.scss @@ -16,7 +16,7 @@ limitations under the License. .mx_MessageEditor { border-radius: 4px; - background-color: #f3f8fd; + background-color: $header-panel-bg-color; padding: 11px 13px 7px 56px; .mx_MessageEditor_editor {