Merge pull request #2049 from matrix-org/t3chguy/slate_cont2

T3chguy/slate cont2
This commit is contained in:
David Baker 2018-07-16 13:25:27 +01:00 committed by GitHub
commit eb497d442b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 310 additions and 247 deletions

88
docs/slate-formats.md Normal file
View File

@ -0,0 +1,88 @@
Guide to data types used by the Slate-based Rich Text Editor
------------------------------------------------------------
We always store the Slate editor state in its Value form.
The schema for the Value is the same whether the editor is in MD or rich text mode, and is currently (rather arbitrarily)
dictated by the schema expected by slate-md-serializer, simply because it was the only bit of the pipeline which
has opinions on the schema. (slate-html-serializer lets you define how to serialize whatever schema you like).
The BLOCK_TAGS and MARK_TAGS give the mapping from HTML tags to the schema's node types (for blocks, which describe
block content like divs, and marks, which describe inline formatted sections like spans).
We use <p/> as the parent tag for the message (XXX: although some tags are technically not allowed to be nested within p's)
Various conversions are performed as content is moved between HTML, MD, and plaintext representations of HTML and MD.
The primitives used are:
* Markdown.js - models commonmark-formatted MD strings (as entered by the composer in MD mode)
* toHtml() - renders them to HTML suitable for sending on the wire
* isPlainText() - checks whether the parsed MD contains anything other than simple text.
* toPlainText() - renders MD to plain text in order to remove backslashes. Only works if the MD is already plaintext (otherwise it just emits HTML)
* slate-html-serializer
* converts Values to HTML (serialising) using our schema rules
* converts HTML to Values (deserialising) using our schema rules
* slate-md-serializer
* converts rich Values to MD strings (serialising) but using a non-commonmark generic MD dialect.
* This should use commonmark, but we use the serializer here for expedience rather than writing a commonmark one.
* slate-plain-serializer
* converts Values to plain text strings (serialising them) by concatenating the strings together
* converts Values from plain text strings (deserialiasing them).
* Used to initialise the editor by deserializing "" into a Value. Apparently this is the idiomatic way to initialise a blank editor.
* Used (as a bodge) to turn a rich text editor into a MD editor, when deserialising the converted MD string of the editor into a value
* PlainWithPillsSerializer
* A fork of slate-plain-serializer which is aware of Pills (hence the name) and Emoji.
* It can be configured to output Pills as:
* "plain": Pills are rendered via their 'completion' text - e.g. 'Matthew'; used for sending messages)
* "md": Pills are rendered as MD, e.g. [Matthew](https://matrix.to/#/@matthew:matrix.org) )
* "id": Pills are rendered as IDs, e.g. '@matthew:matrix.org' (used for authoring / commands)
* Emoji nodes are converted to inline utf8 emoji.
The actual conversion transitions are:
* Quoting:
* The message being quoted is taken as HTML
* ...and deserialised into a Value
* ...and then serialised into MD via slate-md-serializer if the editor is in MD mode
* Roundtripping between MD and rich text editor mode
* From MD to richtext (mdToRichEditorState):
* Serialise the MD-format Value to a MD string (converting pills to MD) with PlainWithPillsSerializer in 'md' mode
* Convert that MD string to HTML via Markdown.js
* Deserialise that Value to HTML via slate-html-serializer
* From richtext to MD (richToMdEditorState):
* Serialise the richtext-format Value to a MD string with slate-md-serializer (XXX: this should use commonmark)
* Deserialise that to a plain text value via slate-plain-serializer
* Loading history in one format into an editor which is in the other format
* Uses the same functions as for roundtripping
* Scanning the editor for a slash command
* If the editor is a single line node starting with /, then serialize it to a string with PlainWithPillsSerializer in 'id' mode
So that pills get converted to IDs suitable for commands being passed around
* Sending messages
* In RT mode:
* If there is rich content, serialize the RT-format Value to HTML body via slate-html-serializer
* Serialize the RT-format Value to the plain text fallback via PlainWithPillsSerializer in 'plain' mode
* In MD mode:
* Serialize the MD-format Value into an MD string with PlainWithPillsSerializer in 'md' mode
* Parse the string with Markdown.js
* If it contains no formatting:
* Send as plaintext (as taken from Markdown.toPlainText())
* Otherwise
* Send as HTML (as taken from Markdown.toHtml())
* Serialize the RT-format Value to the plain text fallback via PlainWithPillsSerializer in 'plain' mode
* Pasting HTML
* Deserialize HTML to a RT Value via slate-html-serializer
* In RT mode, insert it straight into the editor as a fragment
* In MD mode, serialise it to an MD string via slate-md-serializer and then insert the string into the editor as a fragment.
The various scenarios and transitions could be drawn into a pretty diagram if one felt the urge, but hopefully the above
gives sufficient detail on how it's all meant to work.

View File

@ -84,7 +84,7 @@
"react-beautiful-dnd": "^4.0.1",
"react-dom": "^15.6.0",
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef",
"slate": "^0.33.4",
"slate": "0.33.4",
"slate-react": "^0.12.4",
"slate-html-serializer": "^0.6.1",
"slate-md-serializer": "matrix-org/slate-md-serializer#f7c4ad3",

View File

@ -51,8 +51,8 @@ class HistoryItem {
export default class ComposerHistoryManager {
history: Array<HistoryItem> = [];
prefix: string;
lastIndex: number = 0;
currentIndex: number = 0;
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;
@ -69,18 +69,19 @@ export default class ComposerHistoryManager {
}
}
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.lastIndex + 1;
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.lastIndex - 1);
const item = this.history[this.currentIndex];
return item;
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1);
return this.history[this.currentIndex];
}
}

View File

@ -112,42 +112,6 @@ export function charactersToImageNode(alt, useSvg, ...unicode) {
/>;
}
/*
export function processHtmlForSending(html: string): string {
const contentDiv = document.createElement('div');
contentDiv.innerHTML = html;
if (contentDiv.children.length === 0) {
return contentDiv.innerHTML;
}
let contentHTML = "";
for (let i=0; i < contentDiv.children.length; i++) {
const element = contentDiv.children[i];
if (element.tagName.toLowerCase() === 'p') {
contentHTML += element.innerHTML;
// Don't add a <br /> for the last <p>
if (i !== contentDiv.children.length - 1) {
contentHTML += '<br />';
}
} else if (element.tagName.toLowerCase() === 'pre') {
// Replace "<br>\n" with "\n" within `<pre>` tags because the <br> is
// redundant. This is a workaround for a bug in draft-js-export-html:
// https://github.com/sstur/draft-js-export-html/issues/62
contentHTML += '<pre>' +
element.innerHTML.replace(/<br>\n/g, '\n').trim() +
'</pre>';
} else {
const temp = document.createElement('div');
temp.appendChild(element.cloneNode(true));
contentHTML += temp.innerHTML;
}
}
return contentHTML;
}
*/
/*
* Given an untrusted HTML string, return a React node with an sanitized version
* of that HTML.

View File

@ -180,14 +180,6 @@ export default class Markdown {
if (is_multi_line(node) && node.next) this.lit('\n\n');
};
// convert MD links into console-friendly ' < http://foo >' style links
// ...except given this function never gets called with links, it's useless.
// renderer.link = function(node, entering) {
// if (!entering) {
// this.lit(` < ${node.destination} >`);
// }
// };
return renderer.render(this.parsed);
}
}

View File

@ -29,9 +29,9 @@ import NotifProvider from './NotifProvider';
import Promise from 'bluebird';
export type SelectionRange = {
beginning: boolean,
start: number,
end: number
beginning: boolean, // whether the selection is in the first block of the editor or not
start: number, // byte offset relative to the start anchor of the current editor selection.
end: number, // byte offset relative to the end anchor of the current editor selection.
};
export type Completion = {

View File

@ -43,7 +43,7 @@ export default class CommandProvider extends AutocompleteProvider {
let matches = [];
// check if the full match differs from the first word (i.e. returns false if the command has args)
if (command[0] !== command[1]) {
if (command[0] !== command[1]) {
// The input looks like a command with arguments, perform exact match
const name = command[1].substr(1); // strip leading `/`
if (CommandMap[name]) {

View File

@ -111,7 +111,7 @@ export default class UserProvider extends AutocompleteProvider {
// relies on the length of the entity === length of the text in the decoration.
completion: user.rawDisplayName.replace(' (IRC)', ''),
completionId: user.userId,
suffix: (selection.beginning && range.start === 0) ? ': ' : ' ',
suffix: (selection.beginning && selection.start === 0) ? ': ' : ' ',
href: makeUserPermalink(user.userId),
component: (
<PillCompletion

View File

@ -220,7 +220,8 @@ export default class ContextualMenu extends React.Component {
{ chevron }
<ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} />
</div>
{ props.hasBackground && <div className="mx_ContextualMenu_background" onClick={props.closeMenu} onContextMenu={this.onContextMenu} /> }
{ props.hasBackground && <div className="mx_ContextualMenu_background"
onClick={props.closeMenu} onContextMenu={this.onContextMenu} /> }
<style>{ chevronCSS }</style>
</div>;
}

View File

@ -56,7 +56,7 @@ const stateEventTileTypes = {
'm.room.topic': 'messages.TextualEvent',
'm.room.power_levels': 'messages.TextualEvent',
'm.room.pinned_events': 'messages.TextualEvent',
'm.room.server_acl' : 'messages.TextualEvent',
'm.room.server_acl': 'messages.TextualEvent',
'im.vector.modular.widgets': 'messages.TextualEvent',
};

View File

@ -16,7 +16,7 @@ limitations under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler';
import { _t, _td } from '../../../languageHandler';
import CallHandler from '../../../CallHandler';
import MatrixClientPeg from '../../../MatrixClientPeg';
import Modal from '../../../Modal';
@ -26,6 +26,17 @@ import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import Stickerpicker from './Stickerpicker';
const formatButtonList = [
_td("bold"),
_td("italic"),
_td("deleted"),
_td("underlined"),
_td("inline-code"),
_td("block-quote"),
_td("bulleted-list"),
_td("numbered-list"),
];
export default class MessageComposer extends React.Component {
constructor(props, context) {
super(props, context);
@ -322,18 +333,17 @@ export default class MessageComposer extends React.Component {
let formatBar;
if (this.state.showFormatting && this.state.inputState.isRichTextEnabled) {
const {marks, blockType} = this.state.inputState;
const formatButtons = ["bold", "italic", "deleted", "underlined", "inline-code", "block-quote", "bulleted-list", "numbered-list"].map(
(name) => {
const active = marks.some(mark => mark.type === name) || blockType === name;
const suffix = active ? '-on' : '';
const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name);
const className = 'mx_MessageComposer_format_button mx_filterFlipColor';
return <img className={className}
title={_t(name)}
onMouseDown={onFormatButtonClicked}
key={name}
src={`img/button-text-${name}${suffix}.svg`}
height="17" />;
const formatButtons = formatButtonList.map((name) => {
const active = marks.some(mark => mark.type === name) || blockType === name;
const suffix = active ? '-on' : '';
const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name);
const className = 'mx_MessageComposer_format_button mx_filterFlipColor';
return <img className={className}
title={_t(name)}
onMouseDown={onFormatButtonClicked}
key={name}
src={`img/button-text-${name}${suffix}.svg`}
height="17" />;
},
);

View File

@ -21,17 +21,14 @@ import type SyntheticKeyboardEvent from 'react/lib/SyntheticKeyboardEvent';
import { Editor } from 'slate-react';
import { getEventTransfer } from 'slate-react';
import { Value, Document, Event, Block, Inline, Text, Range, Node } from 'slate';
import { Value, Document, Block, Inline, Text, Range, Node } from 'slate';
import type { Change } from 'slate';
import Html from 'slate-html-serializer';
import Md from 'slate-md-serializer';
import Plain from 'slate-plain-serializer';
import PlainWithPillsSerializer from "../../../autocomplete/PlainWithPillsSerializer";
// import {Editor, EditorState, RichUtils, CompositeDecorator, Modifier,
// getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState,
// Entity} from 'draft-js';
import classNames from 'classnames';
import Promise from 'bluebird';
@ -54,7 +51,7 @@ import Markdown from '../../../Markdown';
import ComposerHistoryManager from '../../../ComposerHistoryManager';
import MessageComposerStore from '../../../stores/MessageComposerStore';
import {MATRIXTO_MD_LINK_PATTERN} from '../../../linkify-matrix';
import {MATRIXTO_MD_LINK_PATTERN, MATRIXTO_URL_PATTERN} from '../../../linkify-matrix';
const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g');
import {asciiRegexp, unicodeRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort, toShort} from 'emojione';
@ -118,6 +115,15 @@ function onSendMessageFailed(err, room) {
});
}
function rangeEquals(a: Range, b: Range): boolean {
return (a.anchorKey === b.anchorKey
&& a.anchorOffset === b.anchorOffset
&& a.focusKey === b.focusKey
&& a.focusOffset === b.focusOffset
&& a.isFocused === b.isFocused
&& a.isBackward === b.isBackward);
}
/*
* The textInput part of the MessageComposer
*/
@ -146,29 +152,18 @@ export default class MessageComposerInput extends React.Component {
Analytics.setRichtextMode(isRichTextEnabled);
this.state = {
// whether we're in rich text or markdown mode
isRichTextEnabled,
// the currently displayed editor state (note: this is always what is modified on input)
editorState: this.createEditorState(
isRichTextEnabled,
MessageComposerStore.getEditorState(this.props.room.roomId),
),
// the original editor state, before we started tabbing through completions
originalEditorState: null,
// the virtual state "above" the history stack, the message currently being composed that
// we want to persist whilst browsing history
currentlyComposedEditorState: null,
// whether there were any completions
someCompletions: null,
};
this.client = MatrixClientPeg.get();
// track whether we should be trying to show autocomplete suggestions on the current editor
// contents. currently it's only suppressed when navigating history to avoid ugly flashes
// of unexpected corrections as you navigate.
// XXX: should this be in state?
this.suppressAutoComplete = false;
// track whether we've just pressed an arrowkey left or right in order to skip void nodes.
// see https://github.com/ianstormtaylor/slate/issues/762#issuecomment-304855095
this.direction = '';
this.plainWithMdPills = new PlainWithPillsSerializer({ pillFormat: 'md' });
this.plainWithIdPills = new PlainWithPillsSerializer({ pillFormat: 'id' });
this.plainWithPlainPills = new PlainWithPillsSerializer({ pillFormat: 'plain' });
@ -176,18 +171,35 @@ export default class MessageComposerInput extends React.Component {
this.md = new Md({
rules: [
{
// if serialize returns undefined it falls through to the default hardcoded
// serialization rules
serialize: (obj, children) => {
if (obj.object === 'inline') {
switch (obj.type) {
case 'pill':
return `[${ obj.data.get('completion') }](${ obj.data.get('href') })`;
case 'emoji':
return obj.data.get('emojiUnicode');
}
if (obj.object !== 'inline') return;
switch (obj.type) {
case 'pill':
return `[${ obj.data.get('completion') }](${ obj.data.get('href') })`;
case 'emoji':
return obj.data.get('emojiUnicode');
}
}
}
]
},
}, {
serialize: (obj, children) => {
if (obj.object !== 'mark') return;
// XXX: slate-md-serializer consumes marks other than bold, italic, code, inserted, deleted
switch (obj.type) {
case 'underlined':
return `<u>${ children }</u>`;
case 'deleted':
return `<del>${ children }</del>`;
case 'code':
// XXX: we only ever get given `code` regardless of whether it was inline or block
// XXX: workaround for https://github.com/tommoor/slate-md-serializer/issues/14
// strip single backslashes from children, as they would have been escaped here
return `\`${ children.split('\\').map((v) => v ? v : '\\').join('') }\``;
}
},
},
],
});
this.html = new Html({
@ -278,20 +290,46 @@ export default class MessageComposerInput extends React.Component {
]
});
this.suppressAutoComplete = false;
this.direction = '';
const savedState = MessageComposerStore.getEditorState(this.props.room.roomId);
this.state = {
// whether we're in rich text or markdown mode
isRichTextEnabled,
// the currently displayed editor state (note: this is always what is modified on input)
editorState: this.createEditorState(
isRichTextEnabled,
savedState ? savedState.editor_state : undefined,
savedState ? savedState.rich_text : undefined,
),
// the original editor state, before we started tabbing through completions
originalEditorState: null,
// the virtual state "above" the history stack, the message currently being composed that
// we want to persist whilst browsing history
currentlyComposedEditorState: null,
// whether there were any completions
someCompletions: null,
};
}
/*
* "Does the right thing" to create an Editor value, based on:
* - whether we've got rich text mode enabled
* - contentState was passed in
* - whether the contentState that was passed in was rich text
*/
createEditorState(richText: boolean, editorState: ?Value): Value {
createEditorState(wantRichText: boolean, editorState: ?Value, wasRichText: ?boolean): Value {
if (editorState instanceof Value) {
if (wantRichText && !wasRichText) {
return this.mdToRichEditorState(editorState);
}
if (wasRichText && !wantRichText) {
return this.richToMdEditorState(editorState);
}
return editorState;
}
else {
} else {
// ...or create a new one.
return Plain.deserialize('', { defaultBlock: DEFAULT_NODE });
}
@ -299,7 +337,7 @@ export default class MessageComposerInput extends React.Component {
componentDidMount() {
this.dispatcherRef = dis.register(this.onAction);
this.historyManager = new ComposerHistoryManager(this.props.room.roomId);
this.historyManager = new ComposerHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_');
}
componentWillUnmount() {
@ -342,7 +380,7 @@ export default class MessageComposerInput extends React.Component {
// If so, what should be the format, and how do we differentiate it from replies?
const quote = Block.create('block-quote');
if (this.state.isRichTextEnabled) {
if (this.state.isRichTextEnabled) {
let change = editorState.change();
if (editorState.anchorText.text === '' && editorState.anchorBlock.nodes.size === 1) {
// replace the current block rather than split the block
@ -360,7 +398,6 @@ export default class MessageComposerInput extends React.Component {
let fragmentChange = fragment.change();
fragmentChange.moveToRangeOf(fragment.document)
.wrapBlock(quote);
//.setBlocks('block-quote');
// FIXME: handle pills and use commonmark rather than md-serialize
const md = this.md.serialize(fragmentChange.value);
@ -441,39 +478,37 @@ export default class MessageComposerInput extends React.Component {
}
}
onChange = (change: Change, originalEditorState: value) => {
onChange = (change: Change, originalEditorState?: Value) => {
let editorState = change.value;
if (this.direction !== '') {
const focusedNode = editorState.focusInline || editorState.focusText;
if (focusedNode.isVoid) {
// XXX: does this work in RTL?
const edge = this.direction === 'Previous' ? 'End' : 'Start';
if (editorState.isCollapsed) {
change = change[`collapseToEndOf${ this.direction }Text`]();
}
else {
change = change[`collapseTo${ edge }Of${ this.direction }Text`]();
} else {
const block = this.direction === 'Previous' ? editorState.previousText : editorState.nextText;
if (block) {
change = change.moveFocusToEndOf(block)
change = change[`moveFocusTo${ edge }Of`](block);
}
}
editorState = change.value;
}
}
// when selection changes hide the autocomplete
if (!rangeEquals(this.state.editorState.selection, editorState.selection)) {
this.autocomplete.hide();
}
if (!editorState.document.isEmpty) {
this.onTypingActivity();
} else {
this.onFinishedTyping();
}
/*
// XXX: what was this ever doing?
if (!state.hasOwnProperty('originalEditorState')) {
state.originalEditorState = null;
}
*/
if (editorState.startText !== null) {
const text = editorState.startText.text;
const currentStartOffset = editorState.startOffset;
@ -501,9 +536,7 @@ export default class MessageComposerInput extends React.Component {
}
// emojioneify any emoji
// XXX: is getTextsAsArray a private API?
editorState.document.getTextsAsArray().forEach(node => {
editorState.document.getTexts().forEach(node => {
if (node.text !== '' && HtmlUtils.containsEmoji(node.text)) {
let match;
while ((match = EMOJI_REGEX.exec(node.text)) !== null) {
@ -535,36 +568,6 @@ export default class MessageComposerInput extends React.Component {
editorState = change.value;
}
/*
const currentBlock = editorState.getSelection().getStartKey();
const currentSelection = editorState.getSelection();
const currentStartOffset = editorState.getSelection().getStartOffset();
const block = editorState.getCurrentContent().getBlockForKey(currentBlock);
const text = block.getText();
const entityBeforeCurrentOffset = block.getEntityAt(currentStartOffset - 1);
const entityAtCurrentOffset = block.getEntityAt(currentStartOffset);
// If the cursor is on the boundary between an entity and a non-entity and the
// text before the cursor has whitespace at the end, set the entity state of the
// character before the cursor (the whitespace) to null. This allows the user to
// stop editing the link.
if (entityBeforeCurrentOffset && !entityAtCurrentOffset &&
/\s$/.test(text.slice(0, currentStartOffset))) {
editorState = RichUtils.toggleLink(
editorState,
currentSelection.merge({
anchorOffset: currentStartOffset - 1,
focusOffset: currentStartOffset,
}),
null,
);
// Reset selection
editorState = EditorState.forceSelection(editorState, currentSelection);
}
*/
if (this.props.onInputStateChanged && editorState.blocks.size > 0) {
let blockType = editorState.blocks.first().type;
// console.log("onInputStateChanged; current block type is " + blockType + " and marks are " + editorState.activeMarks);
@ -591,10 +594,10 @@ export default class MessageComposerInput extends React.Component {
dis.dispatch({
action: 'editor_state',
room_id: this.props.room.roomId,
rich_text: this.state.isRichTextEnabled,
editor_state: editorState,
});
/* Since a modification was made, set originalEditorState to null, since newState is now our original */
this.setState({
editorState,
originalEditorState: originalEditorState || null
@ -672,7 +675,7 @@ export default class MessageComposerInput extends React.Component {
hasMark = type => {
const { editorState } = this.state
return editorState.activeMarks.some(mark => mark.type == type)
return editorState.activeMarks.some(mark => mark.type === type)
};
/**
@ -684,10 +687,10 @@ export default class MessageComposerInput extends React.Component {
hasBlock = type => {
const { editorState } = this.state
return editorState.blocks.some(node => node.type == type)
return editorState.blocks.some(node => node.type === type)
};
onKeyDown = (ev: Event, change: Change, editor: Editor) => {
onKeyDown = (ev: KeyboardEvent, change: Change, editor: Editor) => {
this.suppressAutoComplete = false;
@ -702,22 +705,6 @@ export default class MessageComposerInput extends React.Component {
this.direction = '';
}
if (isOnlyCtrlOrCmdKeyEvent(ev)) {
const ctrlCmdCommand = {
// C-m => Toggles between rich text and markdown modes
[KeyCode.KEY_M]: 'toggle-mode',
[KeyCode.KEY_B]: 'bold',
[KeyCode.KEY_I]: 'italic',
[KeyCode.KEY_U]: 'underlined',
[KeyCode.KEY_J]: 'inline-code',
}[ev.keyCode];
if (ctrlCmdCommand) {
return this.handleKeyCommand(ctrlCmdCommand);
}
return;
}
switch (ev.keyCode) {
case KeyCode.ENTER:
return this.handleReturn(ev, change);
@ -731,21 +718,53 @@ export default class MessageComposerInput extends React.Component {
return this.onTab(ev);
case KeyCode.ESCAPE:
return this.onEscape(ev);
default:
// don't intercept it
return;
case KeyCode.SPACE:
return this.onSpace(ev, change);
}
if (isOnlyCtrlOrCmdKeyEvent(ev)) {
const ctrlCmdCommand = {
// C-m => Toggles between rich text and markdown modes
[KeyCode.KEY_M]: 'toggle-mode',
[KeyCode.KEY_B]: 'bold',
[KeyCode.KEY_I]: 'italic',
[KeyCode.KEY_U]: 'underlined',
[KeyCode.KEY_J]: 'inline-code',
}[ev.keyCode];
if (ctrlCmdCommand) {
return this.handleKeyCommand(ctrlCmdCommand);
}
}
};
onBackspace = (ev: Event, change: Change): Change => {
if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.ctrlKey || ev.shiftKey) {
onSpace = (ev: KeyboardEvent, change: Change): Change => {
if (ev.metaKey || ev.altKey || ev.shiftKey || ev.ctrlKey) {
return;
}
// drop a point in history so the user can undo a word
// XXX: this seems nasty but adding to history manually seems a no-go
ev.preventDefault();
return change.setOperationFlag("skip", false).setOperationFlag("merge", false).insertText(ev.key);
};
onBackspace = (ev: KeyboardEvent, change: Change): Change => {
if (ev.metaKey || ev.altKey || ev.shiftKey) {
return;
}
const { editorState } = this.state;
// Allow Ctrl/Cmd-Backspace when focus starts at the start of the composer (e.g select-all)
// for some reason if slate sees you Ctrl-backspace and your anchorOffset=0 it just resets your focus
if (!editorState.isCollapsed && editorState.anchorOffset === 0) {
return change.delete();
}
if (this.state.isRichTextEnabled) {
// let backspace exit lists
const isList = this.hasBlock('list-item');
const { editorState } = this.state;
if (isList && editorState.anchorOffset == 0) {
change
@ -805,7 +824,7 @@ export default class MessageComposerInput extends React.Component {
// Handle the extra wrapping required for list buttons.
const isList = this.hasBlock('list-item');
const isType = editorState.blocks.some(block => {
return !!document.getClosest(block.key, parent => parent.type == type);
return !!document.getClosest(block.key, parent => parent.type === type);
});
if (isList && isType) {
@ -816,7 +835,7 @@ export default class MessageComposerInput extends React.Component {
} else if (isList) {
change
.unwrapBlock(
type == 'bulleted-list' ? 'numbered-list' : 'bulleted-list'
type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list'
)
.wrapBlock(type);
} else {
@ -986,7 +1005,7 @@ export default class MessageComposerInput extends React.Component {
let contentHTML;
// only look for commands if the first block contains simple unformatted text
// i.e. no pills or rich-text formatting.
// i.e. no pills or rich-text formatting and begins with a /.
let cmd, commandText;
const firstChild = editorState.document.nodes.get(0);
const firstGrandChild = firstChild && firstChild.nodes.get(0);
@ -995,7 +1014,7 @@ export default class MessageComposerInput extends React.Component {
firstGrandChild.text[0] === '/')
{
commandText = this.plainWithIdPills.serialize(editorState);
cmd = SlashCommands.processInput(this.props.room.roomId, commandText);
cmd = processCommandInput(this.props.room.roomId, commandText);
}
if (cmd) {
@ -1067,8 +1086,8 @@ export default class MessageComposerInput extends React.Component {
// didn't contain any formatting in the first place...
contentText = mdWithPills.toPlaintext();
} else {
// to avoid ugliness clients which can't parse HTML we don't send pills
// in the plaintext body.
// to avoid ugliness on clients which ignore the HTML body we don't
// send pills in the plaintext body.
contentText = this.plainWithPlainPills.serialize(editorState);
contentHTML = mdWithPills.toHTML();
}
@ -1147,41 +1166,18 @@ export default class MessageComposerInput extends React.Component {
// Select history only if we are not currently auto-completing
if (this.autocomplete.state.completionList.length === 0) {
const selection = this.state.editorState.selection;
// determine whether our cursor is at the top or bottom of the multiline
// input box by just looking at the position of the plain old DOM selection.
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const cursorRect = range.getBoundingClientRect();
// selection must be collapsed
if (!selection.isCollapsed) return;
const document = this.state.editorState.document;
const editorNode = ReactDOM.findDOMNode(this.refs.editor);
const editorRect = editorNode.getBoundingClientRect();
// heuristic to handle tall emoji, pills, etc pushing the cursor away from the top
// or bottom of the page.
// XXX: is this going to break on large inline images or top-to-bottom scripts?
const EDGE_THRESHOLD = 15;
let navigateHistory = false;
// and we must be at the edge of the document (up=start, down=end)
if (up) {
const scrollCorrection = editorNode.scrollTop;
const distanceFromTop = cursorRect.top - editorRect.top + scrollCorrection;
console.log(`Cursor distance from editor top is ${distanceFromTop}`);
if (distanceFromTop < EDGE_THRESHOLD) {
navigateHistory = true;
}
if (!selection.isAtStartOf(document)) return;
} else {
if (!selection.isAtEndOf(document)) return;
}
else {
const scrollCorrection =
editorNode.scrollHeight - editorNode.clientHeight - editorNode.scrollTop;
const distanceFromBottom = editorRect.bottom - cursorRect.bottom + scrollCorrection;
console.log(`Cursor distance from editor bottom is ${distanceFromBottom}`);
if (distanceFromBottom < EDGE_THRESHOLD) {
navigateHistory = true;
}
}
if (!navigateHistory) return;
const selected = this.selectHistory(up);
if (selected) {
@ -1232,11 +1228,8 @@ export default class MessageComposerInput extends React.Component {
// Move selection to the end of the selected history
const change = editorState.change().collapseToEndOf(editorState.document);
// XXX: should we be calling this.onChange(change) now?
// Answer: yes, if we want it to do any of the fixups on stuff like emoji.
// however, this should already have been done and persisted in the history,
// so shouldn't be necessary.
// We don't call this.onChange(change) now, as fixups on stuff like emoji
// should already have been done and persisted in the history.
editorState = change.value;
this.suppressAutoComplete = true;
@ -1339,6 +1332,8 @@ export default class MessageComposerInput extends React.Component {
.insertText(suffix)
.focus();
}
// for good hygiene, keep editorState updated to track the result of the change
// even though we don't do anything subsequently with it
editorState = change.value;
this.onChange(change, activeEditorState);
@ -1437,10 +1432,11 @@ export default class MessageComposerInput extends React.Component {
};
onFormatButtonClicked = (name, e) => {
e.preventDefault(); // don't steal focus from the editor!
e.preventDefault();
// XXX: horrible evil hack to ensure the editor is focused so the act
// of focusing it doesn't then cancel the format button being pressed
// FIXME: can we just tell handleKeyCommand's change to invoke .focus()?
if (document.activeElement && document.activeElement.className !== 'mx_MessageComposer_editor') {
this.refs.editor.focus();
setTimeout(()=>{

View File

@ -406,6 +406,14 @@
"Invited": "Invited",
"Filter room members": "Filter room members",
"%(userName)s (power %(powerLevelNumber)s)": "%(userName)s (power %(powerLevelNumber)s)",
"bold": "bold",
"italic": "italic",
"deleted": "deleted",
"underlined": "underlined",
"inline-code": "inline-code",
"block-quote": "block-quote",
"bulleted-list": "bulleted-list",
"numbered-list": "numbered-list",
"Attachment": "Attachment",
"At this time it is not possible to reply with a file so this will be sent without being a reply.": "At this time it is not possible to reply with a file so this will be sent without being a reply.",
"Upload Files": "Upload Files",
@ -430,14 +438,6 @@
"Command error": "Command error",
"Unable to reply": "Unable to reply",
"At this time it is not possible to reply with an emote.": "At this time it is not possible to reply with an emote.",
"bold": "bold",
"italic": "italic",
"strike": "strike",
"underline": "underline",
"code": "code",
"quote": "quote",
"bullet": "bullet",
"numbullet": "numbullet",
"Markdown is disabled": "Markdown is disabled",
"Markdown is enabled": "Markdown is enabled",
"No pinned messages.": "No pinned messages.",
@ -772,7 +772,6 @@
"Room directory": "Room directory",
"Start chat": "Start chat",
"And %(count)s more...|other": "And %(count)s more...",
"Share Link to User": "Share Link to User",
"ex. @bob:example.com": "ex. @bob:example.com",
"Add User": "Add User",
"Matrix ID": "Matrix ID",

View File

@ -1,5 +1,5 @@
/*
Copyright 2017 Vector Creations Ltd
Copyright 2017, 2018 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@ -15,6 +15,7 @@ limitations under the License.
*/
import dis from '../dispatcher';
import { Store } from 'flux/utils';
import { Value } from 'slate';
const INITIAL_STATE = {
// a map of room_id to rich text editor composer state
@ -54,7 +55,10 @@ class MessageComposerStore extends Store {
_editorState(payload) {
const editorStateMap = this._state.editorStateMap;
editorStateMap[payload.room_id] = payload.editor_state;
editorStateMap[payload.room_id] = {
editor_state: payload.editor_state,
rich_text: payload.rich_text,
};
localStorage.setItem('editor_state', JSON.stringify(editorStateMap));
this._setState({
editorStateMap: editorStateMap,
@ -62,7 +66,15 @@ class MessageComposerStore extends Store {
}
getEditorState(roomId) {
return this._state.editorStateMap[roomId];
const editorStateMap = this._state.editorStateMap;
// const entry = this._state.editorStateMap[roomId];
if (editorStateMap[roomId] && !Value.isValue(editorStateMap[roomId].editor_state)) {
// rehydrate lazily to prevent massive churn at launch and cache it
editorStateMap[roomId].editor_state = Value.fromJSON(editorStateMap[roomId].editor_state);
}
// explicitly don't setState here because the value didn't actually change, we just hydrated it,
// if a listener received an update they too would call this method and have a hydrated Value
return editorStateMap[roomId];
}
reset() {

View File

@ -10,7 +10,6 @@ const MessageComposerInput = sdk.getComponent('views.rooms.MessageComposerInput'
import MatrixClientPeg from '../../../../src/MatrixClientPeg';
import RoomMember from 'matrix-js-sdk';
/*
function addTextToDraft(text) {
const components = document.getElementsByClassName('public-DraftEditor-content');
if (components && components.length) {
@ -21,7 +20,9 @@ function addTextToDraft(text) {
}
}
describe('MessageComposerInput', () => {
// FIXME: These tests need to be updated from Draft to Slate.
xdescribe('MessageComposerInput', () => {
let parentDiv = null,
sandbox = null,
client = null,
@ -300,5 +301,4 @@ describe('MessageComposerInput', () => {
expect(spy.args[0][1].body).toEqual('[Click here](https://some.lovely.url)');
expect(spy.args[0][1].formatted_body).toEqual('<a href="https://some.lovely.url">Click here</a>');
});
});
*/
});