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/_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";
|
||||
|
|
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');
|
||||
}
|
||||
|
||||
.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
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) {
|
||||
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);
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
|
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) => {
|
||||
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}
|
||||
|
|
|
@ -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),
|
||||
};
|
||||
|
|
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",
|
||||
"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",
|
||||
|
|
|
@ -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)"),
|
||||
|
|
Loading…
Reference in a new issue