mirror of
https://github.com/element-hq/element-web
synced 2024-11-25 18:55:58 +03:00
Merge pull request #3025 from matrix-org/bwindels/edit-keyboard-nav
Message editing: arrow key (up/down) navigation between editable events
This commit is contained in:
commit
afd656ae2c
6 changed files with 104 additions and 153 deletions
|
@ -1,86 +0,0 @@
|
||||||
//@flow
|
|
||||||
/*
|
|
||||||
Copyright 2017 Aviral Dasgupta
|
|
||||||
|
|
||||||
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 { Value } from 'slate';
|
|
||||||
|
|
||||||
import _clamp from 'lodash/clamp';
|
|
||||||
|
|
||||||
type MessageFormat = 'rich' | 'markdown';
|
|
||||||
|
|
||||||
class HistoryItem {
|
|
||||||
// We store history items in their native format to ensure history is accurate
|
|
||||||
// and then convert them if our RTE has subsequently changed format.
|
|
||||||
value: Value;
|
|
||||||
format: MessageFormat = 'rich';
|
|
||||||
|
|
||||||
constructor(value: ?Value, format: ?MessageFormat) {
|
|
||||||
this.value = value;
|
|
||||||
this.format = format;
|
|
||||||
}
|
|
||||||
|
|
||||||
static fromJSON(obj: Object): HistoryItem {
|
|
||||||
return new HistoryItem(
|
|
||||||
Value.fromJSON(obj.value),
|
|
||||||
obj.format,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
toJSON(): Object {
|
|
||||||
return {
|
|
||||||
value: this.value.toJSON(),
|
|
||||||
format: this.format,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default class ComposerHistoryManager {
|
|
||||||
history: Array<HistoryItem> = [];
|
|
||||||
prefix: string;
|
|
||||||
lastIndex: number = 0; // used for indexing the storage
|
|
||||||
currentIndex: number = 0; // used for indexing the loaded validated history Array
|
|
||||||
|
|
||||||
constructor(roomId: string, prefix: string = 'mx_composer_history_') {
|
|
||||||
this.prefix = prefix + roomId;
|
|
||||||
|
|
||||||
// TODO: Performance issues?
|
|
||||||
let item;
|
|
||||||
for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) {
|
|
||||||
try {
|
|
||||||
this.history.push(
|
|
||||||
HistoryItem.fromJSON(JSON.parse(item)),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Throwing away unserialisable history", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.lastIndex = this.currentIndex;
|
|
||||||
// reset currentIndex to account for any unserialisable history
|
|
||||||
this.currentIndex = this.history.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
save(value: Value, format: MessageFormat) {
|
|
||||||
const item = new HistoryItem(value, format);
|
|
||||||
this.history.push(item);
|
|
||||||
this.currentIndex = this.history.length;
|
|
||||||
sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON()));
|
|
||||||
}
|
|
||||||
|
|
||||||
getItem(offset: number): ?HistoryItem {
|
|
||||||
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1);
|
|
||||||
return this.history[this.currentIndex];
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -234,6 +234,13 @@ module.exports = React.createClass({
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
scrollToEventIfNeeded: function(eventId) {
|
||||||
|
const node = this.eventNodes[eventId];
|
||||||
|
if (node) {
|
||||||
|
node.scrollIntoView({block: "nearest", behavior: "instant"});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
/* check the scroll state and send out pagination requests if necessary.
|
/* check the scroll state and send out pagination requests if necessary.
|
||||||
*/
|
*/
|
||||||
checkFillState: function() {
|
checkFillState: function() {
|
||||||
|
|
|
@ -408,7 +408,13 @@ const TimelinePanel = React.createClass({
|
||||||
this.forceUpdate();
|
this.forceUpdate();
|
||||||
}
|
}
|
||||||
if (payload.action === "edit_event") {
|
if (payload.action === "edit_event") {
|
||||||
this.setState({editEvent: payload.event});
|
this.setState({editEvent: payload.event}, () => {
|
||||||
|
if (payload.event && this.refs.messagePanel) {
|
||||||
|
this.refs.messagePanel.scrollToEventIfNeeded(
|
||||||
|
payload.event.getId(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -23,6 +23,7 @@ import EditorModel from '../../../editor/model';
|
||||||
import {setCaretPosition} from '../../../editor/caret';
|
import {setCaretPosition} from '../../../editor/caret';
|
||||||
import {getCaretOffsetAndText} from '../../../editor/dom';
|
import {getCaretOffsetAndText} from '../../../editor/dom';
|
||||||
import {htmlSerializeIfNeeded, textSerialize} from '../../../editor/serialize';
|
import {htmlSerializeIfNeeded, textSerialize} from '../../../editor/serialize';
|
||||||
|
import {findEditableEvent} from '../../../utils/EventUtils';
|
||||||
import {parseEvent} from '../../../editor/deserialize';
|
import {parseEvent} from '../../../editor/deserialize';
|
||||||
import Autocomplete from '../rooms/Autocomplete';
|
import Autocomplete from '../rooms/Autocomplete';
|
||||||
import {PartCreator} from '../../../editor/parts';
|
import {PartCreator} from '../../../editor/parts';
|
||||||
|
@ -42,7 +43,7 @@ export default class MessageEditor extends React.Component {
|
||||||
|
|
||||||
constructor(props, context) {
|
constructor(props, context) {
|
||||||
super(props, context);
|
super(props, context);
|
||||||
const room = this.context.matrixClient.getRoom(this.props.event.getRoomId());
|
const room = this._getRoom();
|
||||||
const partCreator = new PartCreator(
|
const partCreator = new PartCreator(
|
||||||
() => this._autocompleteRef,
|
() => this._autocompleteRef,
|
||||||
query => this.setState({query}),
|
query => this.setState({query}),
|
||||||
|
@ -59,6 +60,11 @@ export default class MessageEditor extends React.Component {
|
||||||
};
|
};
|
||||||
this._editorRef = null;
|
this._editorRef = null;
|
||||||
this._autocompleteRef = null;
|
this._autocompleteRef = null;
|
||||||
|
this._hasModifications = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
_getRoom() {
|
||||||
|
return this.context.matrixClient.getRoom(this.props.event.getRoomId());
|
||||||
}
|
}
|
||||||
|
|
||||||
_updateEditorState = (caret) => {
|
_updateEditorState = (caret) => {
|
||||||
|
@ -74,11 +80,22 @@ export default class MessageEditor extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
_onInput = (event) => {
|
_onInput = (event) => {
|
||||||
|
this._hasModifications = true;
|
||||||
const sel = document.getSelection();
|
const sel = document.getSelection();
|
||||||
const {caret, text} = getCaretOffsetAndText(this._editorRef, sel);
|
const {caret, text} = getCaretOffsetAndText(this._editorRef, sel);
|
||||||
this.model.update(text, event.inputType, caret);
|
this.model.update(text, event.inputType, caret);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_isCaretAtStart() {
|
||||||
|
const {caret} = getCaretOffsetAndText(this._editorRef, document.getSelection());
|
||||||
|
return caret.offset === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
_isCaretAtEnd() {
|
||||||
|
const {caret, text} = getCaretOffsetAndText(this._editorRef, document.getSelection());
|
||||||
|
return caret.offset === text.length;
|
||||||
|
}
|
||||||
|
|
||||||
_onKeyDown = (event) => {
|
_onKeyDown = (event) => {
|
||||||
// insert newline on Shift+Enter
|
// insert newline on Shift+Enter
|
||||||
if (event.shiftKey && event.key === "Enter") {
|
if (event.shiftKey && event.key === "Enter") {
|
||||||
|
@ -112,11 +129,33 @@ export default class MessageEditor extends React.Component {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
} else if (event.key === "Escape") {
|
} else if (event.key === "Escape") {
|
||||||
this._cancelEdit();
|
this._cancelEdit();
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
if (this._hasModifications || !this._isCaretAtStart()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previousEvent = findEditableEvent(this._getRoom(), false, this.props.event.getId());
|
||||||
|
if (previousEvent) {
|
||||||
|
dis.dispatch({action: 'edit_event', event: previousEvent});
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
} else if (event.key === "ArrowDown") {
|
||||||
|
if (this._hasModifications || !this._isCaretAtEnd()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextEvent = findEditableEvent(this._getRoom(), true, this.props.event.getId());
|
||||||
|
if (nextEvent) {
|
||||||
|
dis.dispatch({action: 'edit_event', event: nextEvent});
|
||||||
|
} else {
|
||||||
|
dis.dispatch({action: 'edit_event', event: null});
|
||||||
|
dis.dispatch({action: 'focus_composer'});
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_cancelEdit = () => {
|
_cancelEdit = () => {
|
||||||
dis.dispatch({action: "edit_event", event: null});
|
dis.dispatch({action: "edit_event", event: null});
|
||||||
|
dis.dispatch({action: 'focus_composer'});
|
||||||
}
|
}
|
||||||
|
|
||||||
_sendEdit = () => {
|
_sendEdit = () => {
|
||||||
|
@ -147,6 +186,7 @@ export default class MessageEditor extends React.Component {
|
||||||
this.context.matrixClient.sendMessage(roomId, content);
|
this.context.matrixClient.sendMessage(roomId, content);
|
||||||
|
|
||||||
dis.dispatch({action: "edit_event", event: null});
|
dis.dispatch({action: "edit_event", event: null});
|
||||||
|
dis.dispatch({action: 'focus_composer'});
|
||||||
}
|
}
|
||||||
|
|
||||||
_onAutoCompleteConfirm = (completion) => {
|
_onAutoCompleteConfirm = (completion) => {
|
||||||
|
|
|
@ -44,7 +44,6 @@ import * as HtmlUtils from '../../../HtmlUtils';
|
||||||
import Autocomplete from './Autocomplete';
|
import Autocomplete from './Autocomplete';
|
||||||
import {Completion} from "../../../autocomplete/Autocompleter";
|
import {Completion} from "../../../autocomplete/Autocompleter";
|
||||||
import Markdown from '../../../Markdown';
|
import Markdown from '../../../Markdown';
|
||||||
import ComposerHistoryManager from '../../../ComposerHistoryManager';
|
|
||||||
import MessageComposerStore from '../../../stores/MessageComposerStore';
|
import MessageComposerStore from '../../../stores/MessageComposerStore';
|
||||||
import ContentMessages from '../../../ContentMessages';
|
import ContentMessages from '../../../ContentMessages';
|
||||||
|
|
||||||
|
@ -60,6 +59,7 @@ import RoomViewStore from '../../../stores/RoomViewStore';
|
||||||
import ReplyThread from "../elements/ReplyThread";
|
import ReplyThread from "../elements/ReplyThread";
|
||||||
import {ContentHelpers} from 'matrix-js-sdk';
|
import {ContentHelpers} from 'matrix-js-sdk';
|
||||||
import AccessibleButton from '../elements/AccessibleButton';
|
import AccessibleButton from '../elements/AccessibleButton';
|
||||||
|
import {findEditableEvent} from '../../../utils/EventUtils';
|
||||||
|
|
||||||
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
|
const REGEX_EMOTICON_WHITESPACE = new RegExp('(?:^|\\s)(' + EMOTICON_REGEX.source + ')\\s$');
|
||||||
|
|
||||||
|
@ -140,7 +140,6 @@ export default class MessageComposerInput extends React.Component {
|
||||||
|
|
||||||
client: MatrixClient;
|
client: MatrixClient;
|
||||||
autocomplete: Autocomplete;
|
autocomplete: Autocomplete;
|
||||||
historyManager: ComposerHistoryManager;
|
|
||||||
|
|
||||||
constructor(props, context) {
|
constructor(props, context) {
|
||||||
super(props, context);
|
super(props, context);
|
||||||
|
@ -330,7 +329,6 @@ export default class MessageComposerInput extends React.Component {
|
||||||
|
|
||||||
componentWillMount() {
|
componentWillMount() {
|
||||||
this.dispatcherRef = dis.register(this.onAction);
|
this.dispatcherRef = dis.register(this.onAction);
|
||||||
this.historyManager = new ComposerHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
|
@ -1031,7 +1029,6 @@ export default class MessageComposerInput extends React.Component {
|
||||||
|
|
||||||
if (cmd) {
|
if (cmd) {
|
||||||
if (!cmd.error) {
|
if (!cmd.error) {
|
||||||
this.historyManager.save(editorState, this.state.isRichTextEnabled ? 'rich' : 'markdown');
|
|
||||||
this.setState({
|
this.setState({
|
||||||
editorState: this.createEditorState(),
|
editorState: this.createEditorState(),
|
||||||
}, ()=>{
|
}, ()=>{
|
||||||
|
@ -1109,11 +1106,6 @@ export default class MessageComposerInput extends React.Component {
|
||||||
let sendHtmlFn = ContentHelpers.makeHtmlMessage;
|
let sendHtmlFn = ContentHelpers.makeHtmlMessage;
|
||||||
let sendTextFn = ContentHelpers.makeTextMessage;
|
let sendTextFn = ContentHelpers.makeTextMessage;
|
||||||
|
|
||||||
this.historyManager.save(
|
|
||||||
editorState,
|
|
||||||
this.state.isRichTextEnabled ? 'rich' : 'markdown',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (commandText && commandText.startsWith('/me')) {
|
if (commandText && commandText.startsWith('/me')) {
|
||||||
if (replyingToEv) {
|
if (replyingToEv) {
|
||||||
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog");
|
||||||
|
@ -1188,14 +1180,16 @@ export default class MessageComposerInput extends React.Component {
|
||||||
// and we must be at the edge of the document (up=start, down=end)
|
// and we must be at the edge of the document (up=start, down=end)
|
||||||
if (up) {
|
if (up) {
|
||||||
if (!selection.anchor.isAtStartOfNode(document)) return;
|
if (!selection.anchor.isAtStartOfNode(document)) return;
|
||||||
} else {
|
|
||||||
if (!selection.anchor.isAtEndOfNode(document)) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selected = this.selectHistory(up);
|
const editEvent = findEditableEvent(this.props.room, false);
|
||||||
if (selected) {
|
if (editEvent) {
|
||||||
// We're selecting history, so prevent the key event from doing anything else
|
// We're selecting history, so prevent the key event from doing anything else
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
dis.dispatch({
|
||||||
|
action: 'edit_event',
|
||||||
|
event: editEvent,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.moveAutocompleteSelection(up);
|
this.moveAutocompleteSelection(up);
|
||||||
|
@ -1203,54 +1197,6 @@ export default class MessageComposerInput extends React.Component {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
selectHistory = async (up) => {
|
|
||||||
const delta = up ? -1 : 1;
|
|
||||||
|
|
||||||
// True if we are not currently selecting history, but composing a message
|
|
||||||
if (this.historyManager.currentIndex === this.historyManager.history.length) {
|
|
||||||
// We can't go any further - there isn't any more history, so nop.
|
|
||||||
if (!up) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.setState({
|
|
||||||
currentlyComposedEditorState: this.state.editorState,
|
|
||||||
});
|
|
||||||
} else if (this.historyManager.currentIndex + delta === this.historyManager.history.length) {
|
|
||||||
// True when we return to the message being composed currently
|
|
||||||
this.setState({
|
|
||||||
editorState: this.state.currentlyComposedEditorState,
|
|
||||||
});
|
|
||||||
this.historyManager.currentIndex = this.historyManager.history.length;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let editorState;
|
|
||||||
const historyItem = this.historyManager.getItem(delta);
|
|
||||||
if (!historyItem) return;
|
|
||||||
|
|
||||||
if (historyItem.format === 'rich' && !this.state.isRichTextEnabled) {
|
|
||||||
editorState = this.richToMdEditorState(historyItem.value);
|
|
||||||
} else if (historyItem.format === 'markdown' && this.state.isRichTextEnabled) {
|
|
||||||
editorState = this.mdToRichEditorState(historyItem.value);
|
|
||||||
} else {
|
|
||||||
editorState = historyItem.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move selection to the end of the selected history
|
|
||||||
const change = editorState.change().moveToEndOfNode(editorState.document);
|
|
||||||
|
|
||||||
// We don't call this.onChange(change) now, as fixups on stuff like pills
|
|
||||||
// should already have been done and persisted in the history.
|
|
||||||
editorState = change.value;
|
|
||||||
|
|
||||||
this.suppressAutoComplete = true;
|
|
||||||
|
|
||||||
this.setState({ editorState }, ()=>{
|
|
||||||
this._editor.focus();
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
onTab = async (e) => {
|
onTab = async (e) => {
|
||||||
this.setState({
|
this.setState({
|
||||||
someCompletions: null,
|
someCompletions: null,
|
||||||
|
|
|
@ -16,7 +16,7 @@ limitations under the License.
|
||||||
|
|
||||||
import { EventStatus } from 'matrix-js-sdk';
|
import { EventStatus } from 'matrix-js-sdk';
|
||||||
import MatrixClientPeg from '../MatrixClientPeg';
|
import MatrixClientPeg from '../MatrixClientPeg';
|
||||||
|
import shouldHideEvent from "../shouldHideEvent";
|
||||||
/**
|
/**
|
||||||
* Returns whether an event should allow actions like reply, reactions, edit, etc.
|
* Returns whether an event should allow actions like reply, reactions, edit, etc.
|
||||||
* which effectively checks whether it's a regular message that has been sent and that we
|
* which effectively checks whether it's a regular message that has been sent and that we
|
||||||
|
@ -50,3 +50,41 @@ export function canEditContent(mxEvent) {
|
||||||
mxEvent.getOriginalContent().msgtype === "m.text" &&
|
mxEvent.getOriginalContent().msgtype === "m.text" &&
|
||||||
mxEvent.getSender() === MatrixClientPeg.get().getUserId();
|
mxEvent.getSender() === MatrixClientPeg.get().getUserId();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function canEditOwnEvent(mxEvent) {
|
||||||
|
// for now we only allow editing
|
||||||
|
// your own events. So this just call through
|
||||||
|
// In the future though, moderators will be able to
|
||||||
|
// edit other people's messages as well but we don't
|
||||||
|
// want findEditableEvent to return other people's events
|
||||||
|
// hence this method.
|
||||||
|
return canEditContent(mxEvent);
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_JUMP_DISTANCE = 100;
|
||||||
|
export function findEditableEvent(room, isForward, fromEventId = undefined) {
|
||||||
|
const liveTimeline = room.getLiveTimeline();
|
||||||
|
const events = liveTimeline.getEvents();
|
||||||
|
const maxIdx = events.length - 1;
|
||||||
|
const inc = isForward ? 1 : -1;
|
||||||
|
const beginIdx = isForward ? 0 : maxIdx;
|
||||||
|
let endIdx = isForward ? maxIdx : 0;
|
||||||
|
if (!fromEventId) {
|
||||||
|
endIdx = Math.min(Math.max(0, beginIdx + (inc * MAX_JUMP_DISTANCE)), maxIdx);
|
||||||
|
}
|
||||||
|
let foundFromEventId = !fromEventId;
|
||||||
|
for (let i = beginIdx; i !== (endIdx + inc); i += inc) {
|
||||||
|
const e = events[i];
|
||||||
|
// find start event first
|
||||||
|
if (!foundFromEventId && e.getId() === fromEventId) {
|
||||||
|
foundFromEventId = true;
|
||||||
|
// don't look further than MAX_JUMP_DISTANCE events from `fromEventId`
|
||||||
|
// to not iterate potentially 1000nds of events on key up/down
|
||||||
|
endIdx = Math.min(Math.max(0, i + (inc * MAX_JUMP_DISTANCE)), maxIdx);
|
||||||
|
} else if (foundFromEventId && !shouldHideEvent(e) && canEditOwnEvent(e)) {
|
||||||
|
// otherwise look for editable event
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue