Merge pull request #2952 from matrix-org/bwindels/message-edit-editor

Initial support for editing messages
This commit is contained in:
Bruno Windels 2019-05-15 09:23:01 +00:00 committed by GitHub
commit edc100163f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1363 additions and 7 deletions

View File

@ -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";

View File

@ -0,0 +1,64 @@
/*
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.
*/
.mx_MessageEditor {
border-radius: 4px;
background-color: $header-panel-bg-color;
padding: 11px 13px 7px 56px;
.mx_MessageEditor_editor {
border-radius: 4px;
border: solid 1px #e9edf1;
background-color: #ffffff;
padding: 10px;
white-space: pre-wrap;
word-wrap: break-word;
outline: none;
span {
display: inline-block;
padding: 0 5px;
border-radius: 4px;
color: white;
}
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;
}
}
.mx_MessageEditor_buttons {
display: flex;
flex-direction: row;
justify-content: end;
padding: 5px 0;
.mx_AccessibleButton {
margin-left: 5px;
padding: 5px 40px;
}
}
.mx_MessageEditor_AutoCompleteWrapper {
position: relative;
height: 0;
}
}

View File

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

1
res/img/edit.svg Normal file
View File

@ -0,0 +1 @@
<svg height="14.865319" viewBox="0 0 15.093 14.865319" width="15.093" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><filter id="a" height="1.158" width="1.118" x="-.059" y="-.079"><feOffset dy="2" in="SourceAlpha" result="shadowOffsetOuter1"/><feGaussianBlur in="shadowOffsetOuter1" result="shadowBlurOuter1" stdDeviation="16"/><feColorMatrix in="shadowBlurOuter1" result="shadowMatrixOuter1" values="0 0 0 0 0 0 0 0 0 0.473684211 0 0 0 0 1 0 0 0 0.241258741 0"/><feMerge><feMergeNode in="shadowMatrixOuter1"/><feMergeNode in="SourceGraphic"/></feMerge></filter><g style="fill:none;fill-rule:evenodd;stroke:#2e2f32;stroke-width:.75;stroke-linecap:round;stroke-linejoin:round;filter:url(#a)" transform="translate(-1048.2035 -582.14881)"><path d="m1055.75 596h6.75m-3.375-12.375a1.591 1.591 0 0 1 2.25 2.25l-9.375 9.375-3 .75.75-3z"/></g></svg>

After

Width:  |  Height:  |  Size: 874 B

View File

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

View File

@ -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 [<MessageEditor key={mxEv.getId()} event={mxEv} />];
}
// is this a continuation of the previous message?
let continuation = false;

View File

@ -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);
@ -402,6 +404,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) {
@ -502,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;
@ -1244,6 +1260,7 @@ const TimelinePanel = React.createClass({
tileShape={this.props.tileShape}
resizeNotifier={this.props.resizeNotifier}
getRelationsForEvent={this.getRelationsForEvent}
editEvent={this.state.editEvent}
/>
);
},

View File

@ -0,0 +1,176 @@
/*
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 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/deserialize';
import Autocomplete from '../rooms/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 message event being edited
event: PropTypes.instanceOf(MatrixEvent).isRequired,
};
static contextTypes = {
matrixClient: PropTypes.instanceOf(MatrixClient).isRequired,
};
constructor(props, context) {
super(props, context);
const partCreator = new PartCreator(
() => this._autocompleteRef,
query => this.setState({query}),
);
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,
room,
};
this._editorRef = null;
this._autocompleteRef = null;
}
_updateEditorState = (caret) => {
renderModel(this._editorRef, this.model);
if (caret) {
try {
setCaretPosition(this._editorRef, this.model, caret);
} catch (err) {
console.error(err);
}
}
this.setState({autoComplete: this.model.autoComplete});
}
_onInput = (event) => {
const sel = document.getSelection();
const {caret, text} = getCaretOffsetAndText(this._editorRef, sel);
this.model.update(text, event.inputType, caret);
}
_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 = () => {
dis.dispatch({action: "edit_event", event: null});
}
_onSaveClicked = () => {
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(),
},
}, newContent);
const roomId = this.props.event.getRoomId();
this.context.matrixClient.sendMessage(roomId, content);
dis.dispatch({action: "edit_event", event: null});
}
_onAutoCompleteConfirm = (completion) => {
this.model.autoComplete.onComponentConfirm(completion);
}
_onAutoCompleteSelectionChange = (completion) => {
this.model.autoComplete.onComponentSelectionChange(completion);
}
componentDidMount() {
this._updateEditorState();
}
render() {
let autoComplete;
if (this.state.autoComplete) {
const query = this.state.query;
const queryLen = query.length;
autoComplete = <div className="mx_MessageEditor_AutoCompleteWrapper">
<Autocomplete
ref={ref => this._autocompleteRef = ref}
query={query}
onConfirm={this._onAutoCompleteConfirm}
onSelectionChange={this._onAutoCompleteSelectionChange}
selection={{beginning: true, end: queryLen, start: queryLen}}
room={this.state.room}
/>
</div>;
}
const AccessibleButton = sdk.getComponent('elements.AccessibleButton');
return <div className="mx_MessageEditor">
{ autoComplete }
<div
className="mx_MessageEditor_editor"
contentEditable="true"
tabIndex="1"
onInput={this._onInput}
onKeyDown={this._onKeyDown}
ref={ref => this._editorRef = ref}
></div>
<div className="mx_MessageEditor_buttons">
<AccessibleButton kind="secondary" onClick={this._onCancelClicked}>{_t("Cancel")}</AccessibleButton>
<AccessibleButton kind="primary" onClick={this._onSaveClicked}>{_t("Save")}</AccessibleButton>
</div>
</div>;
}
}

View File

@ -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();
@ -96,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;
@ -128,6 +139,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 +148,19 @@ export default class MessageActionBar extends React.PureComponent {
title={_t("Reply")}
onClick={this.onReplyClick}
/>;
if (this.isEditingEnabled()) {
editButton = <span className="mx_MessageActionBar_maskButton mx_MessageActionBar_editButton"
title={_t("Edit")}
onClick={this.onEditClick}
/>;
}
}
return <div className="mx_MessageActionBar">
{agreeDimensionReactionButtons}
{likeDimensionReactionButtons}
{replyButton}
{editButton}
<span className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
title={_t("Options")}
onClick={this.onOptionsClick}

View File

@ -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) {
@ -298,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),
};

View File

@ -0,0 +1,97 @@
/*
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);
this._updateCallback({
replacePart: new PlainPart(this._queryPart.text),
caretOffset: this._queryOffset,
close: true,
});
}
onEnter() {
this._updateCallback({close: true});
}
onTab() {
//forceCompletion here?
}
onUpArrow() {
this._getAutocompleterComponent().onUpArrow();
}
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,
caretOffset: 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);
}
// also used for emoji completion
default:
return new PlainPart(completion.completion);
}
}
}

56
src/editor/caret.js Normal file
View File

@ -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.
*/
export function setCaretPosition(editor, model, caretPosition) {
const sel = document.getSelection();
sel.removeAllRanges();
const range = document.createRange();
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
range.setStart(focusNode, caretPosition.offset);
range.collapse(true);
}
sel.addRange(range);
}

75
src/editor/deserialize.js Normal file
View File

@ -0,0 +1,75 @@
/*
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, NewlinePart } 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) {
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, n.textContent);
case "#": return new RoomPillPart(resourceId, n.textContent);
default: return new PlainPart(n.textContent);
}
}
case "BR":
return new NewlinePart("\n");
default:
return new PlainPart(n.textContent);
}
default:
return null;
}
}).filter(p => !!p);
return parts;
}
export function parseEvent(event) {
const content = event.getContent();
if (content.format === "org.matrix.custom.html") {
return parseHtmlMessage(content.formatted_body);
} else {
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;
}
}

78
src/editor/diff.js Normal file
View File

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

84
src/editor/dom.js Normal file
View File

@ -0,0 +1,84 @@
/*
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 {focusNode} = sel;
const {focusOffset} = 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};
}

264
src/editor/model.js Normal file
View File

@ -0,0 +1,264 @@
/*
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 {diffAtCaret, diffDeletion} from "./diff";
export default class EditorModel {
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) {
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) {
this._parts.splice(index, 1, part);
}
get parts() {
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) {
const previousValue = this.parts.reduce((text, p) => text + p.text, "");
// can't use caret position with drag and drop
if (inputType === "deleteByDrag") {
return diffDeletion(previousValue, newValue);
} else {
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);
let removedOffsetDecrease = 0;
if (diff.removed) {
removedOffsetDecrease = this._removeText(position, diff.removed.length);
}
let addedLen = 0;
if (diff.added) {
addedLen = this._addText(position, diff.added);
}
this._mergeAdjacentParts();
const caretOffset = diff.at - removedOffsetDecrease + addedLen;
const newPosition = this._positionForOffset(caretOffset, true);
this._setActivePart(newPosition);
this._updateCallback(newPosition);
}
_setActivePart(pos) {
const {index} = pos;
const part = this._parts[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;
}
}
}
// 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;
}
}
_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);
}
if (close) {
this._autoComplete = null;
this._autoCompletePartIdx = null;
}
// rerender even if editor contents didn't change
// to make sure the MessageEditor checks
// model.autoComplete being empty and closes it
this._updateCallback(pos);
}
_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;
}
}
/**
* 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.
*/
_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 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;
}
} else {
removedOffsetDecrease += offset;
this._removePart(index);
}
len -= amount;
offset = 0;
}
return removedOffsetDecrease;
}
/**
* 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) {
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 {
const splitPart = part.split(offset);
index += 1;
this._insertPart(index, splitPart);
}
} else {
// not-editable, insert str after this part
addLen += part.text.length - offset;
index += 1;
}
}
while (str) {
const newPart = this._partCreator.createPartForInput(str);
str = newPart.appendUntilRejected(str);
this._insertPart(index, newPart);
index += 1;
}
return addLen;
}
_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;
}
}

274
src/editor/parts.js Normal file
View File

@ -0,0 +1,274 @@
/*
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 AutocompleteWrapperModel from "./autocomplete";
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.
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;
}
createAutoComplete() {}
trim(len) {
const remaining = this._text.substr(len);
this._text = this._text.substr(0, len);
return remaining;
}
get text() {
return this._text;
}
get canEdit() {
return true;
}
toString() {
return `${this.type}(${this.text})`;
}
}
export class PlainPart extends BasePart {
acceptsInsertion(chr) {
return chr !== "@" && chr !== "#" && chr !== ":" && chr !== "\n";
}
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 {
constructor(resourceId, label) {
super(label);
this.resourceId = resourceId;
}
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;
}
get canEdit() {
return false;
}
}
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);
}
get type() {
return "room-pill";
}
}
export class UserPillPart extends PillPart {
get type() {
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);
case "\n":
return new NewlinePart();
default:
return new PlainPart();
}
}
createDefaultPart(text) {
return new PlainPart(text);
}
}

81
src/editor/render.js Normal file
View File

@ -0,0 +1,81 @@
/*
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 renderModel(editor, model) {
const lines = model.parts.reduce((lines, part) => {
if (part.type === "newline") {
lines.push([]);
} else {
const lastLine = lines[lines.length - 1];
lastLine.push(part);
}
return lines;
}, [[]]);
// TODO: refactor this code, DRY it
lines.forEach((parts, i) => {
let lineContainer = editor.childNodes[i];
while (lineContainer && (lineContainer.tagName !== "DIV" || !!lineContainer.className)) {
editor.removeChild(lineContainer);
lineContainer = editor.childNodes[i];
}
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 to give it height
let foundBR = false;
let partNode = lineContainer.firstChild;
while (partNode) {
if (!foundBR && partNode.tagName === "BR") {
foundBR = true;
} else {
lineContainer.removeChild(partNode);
}
partNode = partNode.nextSibling;
}
if (!foundBR) {
lineContainer.appendChild(document.createElement("br"));
}
}
let surplusElementCount = Math.max(0, editor.childNodes.length - lines.length);
while (surplusElementCount) {
editor.removeChild(editor.lastChild);
--surplusElementCount;
}
});
}

43
src/editor/serialize.js Normal file
View File

@ -0,0 +1,43 @@
export function htmlSerialize(model) {
return model.parts.reduce((html, part) => {
switch (part.type) {
case "newline":
return html + "<br />";
case "plain":
case "pill-candidate":
return html + part.text;
case "room-pill":
case "user-pill":
return html + `<a href="https://matrix.to/#/${part.resourceId}">${part.text}</a>`;
}
}, "");
}
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;
}
});
}

View File

@ -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 (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",
@ -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",

View File

@ -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 (refresh to apply changes)"),
supportedLevels: LEVELS_FEATURE,
default: false,
},
"feature_reactions": {
isFeature: true,
displayName: _td("React to messages with emoji (refresh to apply changes)"),