Merge pull request #2952 from matrix-org/bwindels/message-edit-editor
Initial support for editing messages
This commit is contained in:
commit
edc100163f
21 changed files with 1363 additions and 7 deletions
|
@ -89,6 +89,7 @@
|
||||||
@import "./views/elements/_InlineSpinner.scss";
|
@import "./views/elements/_InlineSpinner.scss";
|
||||||
@import "./views/elements/_ManageIntegsButton.scss";
|
@import "./views/elements/_ManageIntegsButton.scss";
|
||||||
@import "./views/elements/_MemberEventListSummary.scss";
|
@import "./views/elements/_MemberEventListSummary.scss";
|
||||||
|
@import "./views/elements/_MessageEditor.scss";
|
||||||
@import "./views/elements/_PowerSelector.scss";
|
@import "./views/elements/_PowerSelector.scss";
|
||||||
@import "./views/elements/_ProgressBar.scss";
|
@import "./views/elements/_ProgressBar.scss";
|
||||||
@import "./views/elements/_ReplyThread.scss";
|
@import "./views/elements/_ReplyThread.scss";
|
||||||
|
|
64
res/css/views/elements/_MessageEditor.scss
Normal file
64
res/css/views/elements/_MessageEditor.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -69,6 +69,10 @@ limitations under the License.
|
||||||
mask-image: url('$(res)/img/reply.svg');
|
mask-image: url('$(res)/img/reply.svg');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mx_MessageActionBar_editButton::after {
|
||||||
|
mask-image: url('$(res)/img/edit.svg');
|
||||||
|
}
|
||||||
|
|
||||||
.mx_MessageActionBar_optionsButton::after {
|
.mx_MessageActionBar_optionsButton::after {
|
||||||
mask-image: url('$(res)/img/icon_context.svg');
|
mask-image: url('$(res)/img/icon_context.svg');
|
||||||
}
|
}
|
||||||
|
|
1
res/img/edit.svg
Normal file
1
res/img/edit.svg
Normal 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 |
|
@ -176,6 +176,7 @@ class MatrixClientPeg {
|
||||||
|
|
||||||
_createClient(creds: MatrixClientCreds) {
|
_createClient(creds: MatrixClientCreds) {
|
||||||
const aggregateRelations = SettingsStore.isFeatureEnabled("feature_reactions");
|
const aggregateRelations = SettingsStore.isFeatureEnabled("feature_reactions");
|
||||||
|
const enableEdits = SettingsStore.isFeatureEnabled("feature_message_editing");
|
||||||
|
|
||||||
const opts = {
|
const opts = {
|
||||||
baseUrl: creds.homeserverUrl,
|
baseUrl: creds.homeserverUrl,
|
||||||
|
@ -187,6 +188,7 @@ class MatrixClientPeg {
|
||||||
forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false),
|
forceTURN: !SettingsStore.getValue('webRtcAllowPeerToPeer', false),
|
||||||
verificationMethods: [verificationMethods.SAS],
|
verificationMethods: [verificationMethods.SAS],
|
||||||
unstableClientRelationAggregation: aggregateRelations,
|
unstableClientRelationAggregation: aggregateRelations,
|
||||||
|
unstableClientRelationReplacements: enableEdits,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.matrixClient = createMatrixClient(opts);
|
this.matrixClient = createMatrixClient(opts);
|
||||||
|
|
|
@ -450,9 +450,14 @@ module.exports = React.createClass({
|
||||||
|
|
||||||
_getTilesForEvent: function(prevEvent, mxEv, last) {
|
_getTilesForEvent: function(prevEvent, mxEv, last) {
|
||||||
const EventTile = sdk.getComponent('rooms.EventTile');
|
const EventTile = sdk.getComponent('rooms.EventTile');
|
||||||
|
const MessageEditor = sdk.getComponent('elements.MessageEditor');
|
||||||
const DateSeparator = sdk.getComponent('messages.DateSeparator');
|
const DateSeparator = sdk.getComponent('messages.DateSeparator');
|
||||||
const ret = [];
|
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?
|
// is this a continuation of the previous message?
|
||||||
let continuation = false;
|
let continuation = false;
|
||||||
|
|
||||||
|
|
|
@ -204,6 +204,7 @@ const TimelinePanel = React.createClass({
|
||||||
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
|
MatrixClientPeg.get().on("Room.timeline", this.onRoomTimeline);
|
||||||
MatrixClientPeg.get().on("Room.timelineReset", this.onRoomTimelineReset);
|
MatrixClientPeg.get().on("Room.timelineReset", this.onRoomTimelineReset);
|
||||||
MatrixClientPeg.get().on("Room.redaction", this.onRoomRedaction);
|
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.receipt", this.onRoomReceipt);
|
||||||
MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated);
|
MatrixClientPeg.get().on("Room.localEchoUpdated", this.onLocalEchoUpdated);
|
||||||
MatrixClientPeg.get().on("Room.accountData", this.onAccountData);
|
MatrixClientPeg.get().on("Room.accountData", this.onAccountData);
|
||||||
|
@ -282,6 +283,7 @@ const TimelinePanel = React.createClass({
|
||||||
client.removeListener("Room.timeline", this.onRoomTimeline);
|
client.removeListener("Room.timeline", this.onRoomTimeline);
|
||||||
client.removeListener("Room.timelineReset", this.onRoomTimelineReset);
|
client.removeListener("Room.timelineReset", this.onRoomTimelineReset);
|
||||||
client.removeListener("Room.redaction", this.onRoomRedaction);
|
client.removeListener("Room.redaction", this.onRoomRedaction);
|
||||||
|
client.removeListener("Room.replaceEvent", this.onRoomReplaceEvent);
|
||||||
client.removeListener("Room.receipt", this.onRoomReceipt);
|
client.removeListener("Room.receipt", this.onRoomReceipt);
|
||||||
client.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated);
|
client.removeListener("Room.localEchoUpdated", this.onLocalEchoUpdated);
|
||||||
client.removeListener("Room.accountData", this.onAccountData);
|
client.removeListener("Room.accountData", this.onAccountData);
|
||||||
|
@ -402,6 +404,9 @@ const TimelinePanel = React.createClass({
|
||||||
if (payload.action === 'ignore_state_changed') {
|
if (payload.action === 'ignore_state_changed') {
|
||||||
this.forceUpdate();
|
this.forceUpdate();
|
||||||
}
|
}
|
||||||
|
if (payload.action === "edit_event") {
|
||||||
|
this.setState({editEvent: payload.event});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
|
onRoomTimeline: function(ev, room, toStartOfTimeline, removed, data) {
|
||||||
|
@ -502,6 +507,17 @@ const TimelinePanel = React.createClass({
|
||||||
this.forceUpdate();
|
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) {
|
onRoomReceipt: function(ev, room) {
|
||||||
if (this.unmounted) return;
|
if (this.unmounted) return;
|
||||||
|
|
||||||
|
@ -1244,6 +1260,7 @@ const TimelinePanel = React.createClass({
|
||||||
tileShape={this.props.tileShape}
|
tileShape={this.props.tileShape}
|
||||||
resizeNotifier={this.props.resizeNotifier}
|
resizeNotifier={this.props.resizeNotifier}
|
||||||
getRelationsForEvent={this.getRelationsForEvent}
|
getRelationsForEvent={this.getRelationsForEvent}
|
||||||
|
editEvent={this.state.editEvent}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
176
src/components/views/elements/MessageEditor.js
Normal file
176
src/components/views/elements/MessageEditor.js
Normal 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>;
|
||||||
|
}
|
||||||
|
}
|
|
@ -58,6 +58,13 @@ export default class MessageActionBar extends React.PureComponent {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onEditClick = (ev) => {
|
||||||
|
dis.dispatch({
|
||||||
|
action: 'edit_event',
|
||||||
|
event: this.props.mxEvent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
onOptionsClick = (ev) => {
|
onOptionsClick = (ev) => {
|
||||||
const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
|
const MessageContextMenu = sdk.getComponent('context_menus.MessageContextMenu');
|
||||||
const buttonRect = ev.target.getBoundingClientRect();
|
const buttonRect = ev.target.getBoundingClientRect();
|
||||||
|
@ -96,6 +103,10 @@ export default class MessageActionBar extends React.PureComponent {
|
||||||
return SettingsStore.isFeatureEnabled("feature_reactions");
|
return SettingsStore.isFeatureEnabled("feature_reactions");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isEditingEnabled() {
|
||||||
|
return SettingsStore.isFeatureEnabled("feature_message_editing");
|
||||||
|
}
|
||||||
|
|
||||||
renderAgreeDimension() {
|
renderAgreeDimension() {
|
||||||
if (!this.isReactionsEnabled()) {
|
if (!this.isReactionsEnabled()) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -128,6 +139,7 @@ export default class MessageActionBar extends React.PureComponent {
|
||||||
let agreeDimensionReactionButtons;
|
let agreeDimensionReactionButtons;
|
||||||
let likeDimensionReactionButtons;
|
let likeDimensionReactionButtons;
|
||||||
let replyButton;
|
let replyButton;
|
||||||
|
let editButton;
|
||||||
|
|
||||||
if (isContentActionable(this.props.mxEvent)) {
|
if (isContentActionable(this.props.mxEvent)) {
|
||||||
agreeDimensionReactionButtons = this.renderAgreeDimension();
|
agreeDimensionReactionButtons = this.renderAgreeDimension();
|
||||||
|
@ -136,12 +148,19 @@ export default class MessageActionBar extends React.PureComponent {
|
||||||
title={_t("Reply")}
|
title={_t("Reply")}
|
||||||
onClick={this.onReplyClick}
|
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">
|
return <div className="mx_MessageActionBar">
|
||||||
{agreeDimensionReactionButtons}
|
{agreeDimensionReactionButtons}
|
||||||
{likeDimensionReactionButtons}
|
{likeDimensionReactionButtons}
|
||||||
{replyButton}
|
{replyButton}
|
||||||
|
{editButton}
|
||||||
<span className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
|
<span className="mx_MessageActionBar_maskButton mx_MessageActionBar_optionsButton"
|
||||||
title={_t("Options")}
|
title={_t("Options")}
|
||||||
onClick={this.onOptionsClick}
|
onClick={this.onOptionsClick}
|
||||||
|
|
|
@ -60,18 +60,22 @@ export default class Autocomplete extends React.Component {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillReceiveProps(newProps, state) {
|
componentDidMount() {
|
||||||
if (this.props.room.roomId !== newProps.room.roomId) {
|
this._applyNewProps();
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyNewProps(oldQuery, oldRoom) {
|
||||||
|
if (oldRoom && this.props.room.roomId !== oldRoom.roomId) {
|
||||||
this.autocompleter.destroy();
|
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
|
// Query hasn't changed so don't try to complete it
|
||||||
if (newProps.query === this.props.query) {
|
if (oldQuery === this.props.query) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.complete(newProps.query, newProps.selection);
|
this.complete(this.props.query, this.props.selection);
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
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
|
// this is the selected completion, so scroll it into view if needed
|
||||||
const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`];
|
const selectedCompletion = this.refs[`completion${this.state.selectionOffset}`];
|
||||||
if (selectedCompletion && this.container) {
|
if (selectedCompletion && this.container) {
|
||||||
|
@ -298,6 +303,9 @@ Autocomplete.propTypes = {
|
||||||
// method invoked with range and text content when completion is confirmed
|
// method invoked with range and text content when completion is confirmed
|
||||||
onConfirm: PropTypes.func.isRequired,
|
onConfirm: PropTypes.func.isRequired,
|
||||||
|
|
||||||
|
// method invoked when selected (if any) completion changes
|
||||||
|
onSelectionChange: PropTypes.func,
|
||||||
|
|
||||||
// The room in which we're autocompleting
|
// The room in which we're autocompleting
|
||||||
room: PropTypes.instanceOf(Room),
|
room: PropTypes.instanceOf(Room),
|
||||||
};
|
};
|
||||||
|
|
97
src/editor/autocomplete.js
Normal file
97
src/editor/autocomplete.js
Normal 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
56
src/editor/caret.js
Normal 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
75
src/editor/deserialize.js
Normal 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
78
src/editor/diff.js
Normal 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
84
src/editor/dom.js
Normal 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
264
src/editor/model.js
Normal 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
274
src/editor/parts.js
Normal 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
81
src/editor/render.js
Normal 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
43
src/editor/serialize.js
Normal 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
|
@ -300,6 +300,7 @@
|
||||||
"Show recent room avatars above the room list": "Show recent room avatars above the room list",
|
"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)",
|
"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",
|
"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)",
|
"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",
|
"Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
|
||||||
"Use compact timeline layout": "Use compact timeline layout",
|
"Use compact timeline layout": "Use compact timeline layout",
|
||||||
|
@ -897,6 +898,7 @@
|
||||||
"Agree or Disagree": "Agree or Disagree",
|
"Agree or Disagree": "Agree or Disagree",
|
||||||
"Like or Dislike": "Like or Dislike",
|
"Like or Dislike": "Like or Dislike",
|
||||||
"Reply": "Reply",
|
"Reply": "Reply",
|
||||||
|
"Edit": "Edit",
|
||||||
"Options": "Options",
|
"Options": "Options",
|
||||||
"Attachment": "Attachment",
|
"Attachment": "Attachment",
|
||||||
"Error decrypting attachment": "Error decrypting attachment",
|
"Error decrypting attachment": "Error decrypting attachment",
|
||||||
|
@ -973,7 +975,6 @@
|
||||||
"Reload widget": "Reload widget",
|
"Reload widget": "Reload widget",
|
||||||
"Popout widget": "Popout widget",
|
"Popout widget": "Popout widget",
|
||||||
"Picture": "Picture",
|
"Picture": "Picture",
|
||||||
"Edit": "Edit",
|
|
||||||
"Revoke widget access": "Revoke widget access",
|
"Revoke widget access": "Revoke widget access",
|
||||||
"Create new room": "Create new room",
|
"Create new room": "Create new room",
|
||||||
"Unblacklist": "Unblacklist",
|
"Unblacklist": "Unblacklist",
|
||||||
|
|
|
@ -118,6 +118,12 @@ export const SETTINGS = {
|
||||||
supportedLevels: LEVELS_FEATURE,
|
supportedLevels: LEVELS_FEATURE,
|
||||||
default: false,
|
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": {
|
"feature_reactions": {
|
||||||
isFeature: true,
|
isFeature: true,
|
||||||
displayName: _td("React to messages with emoji (refresh to apply changes)"),
|
displayName: _td("React to messages with emoji (refresh to apply changes)"),
|
||||||
|
|
Loading…
Reference in a new issue