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-beautiful-dnd": "^4.0.1",
"react-dom": "^15.6.0", "react-dom": "^15.6.0",
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef",
"slate": "^0.33.4", "slate": "0.33.4",
"slate-react": "^0.12.4", "slate-react": "^0.12.4",
"slate-html-serializer": "^0.6.1", "slate-html-serializer": "^0.6.1",
"slate-md-serializer": "matrix-org/slate-md-serializer#f7c4ad3", "slate-md-serializer": "matrix-org/slate-md-serializer#f7c4ad3",

View file

@ -51,8 +51,8 @@ class HistoryItem {
export default class ComposerHistoryManager { export default class ComposerHistoryManager {
history: Array<HistoryItem> = []; history: Array<HistoryItem> = [];
prefix: string; prefix: string;
lastIndex: number = 0; lastIndex: number = 0; // used for indexing the storage
currentIndex: number = 0; currentIndex: number = 0; // used for indexing the loaded validated history Array
constructor(roomId: string, prefix: string = 'mx_composer_history_') { constructor(roomId: string, prefix: string = 'mx_composer_history_') {
this.prefix = prefix + roomId; this.prefix = prefix + roomId;
@ -69,18 +69,19 @@ export default class ComposerHistoryManager {
} }
} }
this.lastIndex = this.currentIndex; this.lastIndex = this.currentIndex;
// reset currentIndex to account for any unserialisable history
this.currentIndex = this.history.length;
} }
save(value: Value, format: MessageFormat) { save(value: Value, format: MessageFormat) {
const item = new HistoryItem(value, format); const item = new HistoryItem(value, format);
this.history.push(item); this.history.push(item);
this.currentIndex = this.lastIndex + 1; this.currentIndex = this.history.length;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON())); sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON()));
} }
getItem(offset: number): ?HistoryItem { getItem(offset: number): ?HistoryItem {
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.lastIndex - 1); this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1);
const item = this.history[this.currentIndex]; return this.history[this.currentIndex];
return item;
} }
} }

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 * Given an untrusted HTML string, return a React node with an sanitized version
* of that HTML. * of that HTML.

View file

@ -180,14 +180,6 @@ export default class Markdown {
if (is_multi_line(node) && node.next) this.lit('\n\n'); 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); return renderer.render(this.parsed);
} }
} }

View file

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

View file

@ -43,7 +43,7 @@ export default class CommandProvider extends AutocompleteProvider {
let matches = []; let matches = [];
// check if the full match differs from the first word (i.e. returns false if the command has args) // 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 // The input looks like a command with arguments, perform exact match
const name = command[1].substr(1); // strip leading `/` const name = command[1].substr(1); // strip leading `/`
if (CommandMap[name]) { 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. // relies on the length of the entity === length of the text in the decoration.
completion: user.rawDisplayName.replace(' (IRC)', ''), completion: user.rawDisplayName.replace(' (IRC)', ''),
completionId: user.userId, completionId: user.userId,
suffix: (selection.beginning && range.start === 0) ? ': ' : ' ', suffix: (selection.beginning && selection.start === 0) ? ': ' : ' ',
href: makeUserPermalink(user.userId), href: makeUserPermalink(user.userId),
component: ( component: (
<PillCompletion <PillCompletion

View file

@ -220,7 +220,8 @@ export default class ContextualMenu extends React.Component {
{ chevron } { chevron }
<ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} /> <ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} />
</div> </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> <style>{ chevronCSS }</style>
</div>; </div>;
} }

View file

@ -56,7 +56,7 @@ const stateEventTileTypes = {
'm.room.topic': 'messages.TextualEvent', 'm.room.topic': 'messages.TextualEvent',
'm.room.power_levels': 'messages.TextualEvent', 'm.room.power_levels': 'messages.TextualEvent',
'm.room.pinned_events': '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', 'im.vector.modular.widgets': 'messages.TextualEvent',
}; };

View file

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

View file

@ -21,17 +21,14 @@ import type SyntheticKeyboardEvent from 'react/lib/SyntheticKeyboardEvent';
import { Editor } from 'slate-react'; import { Editor } from 'slate-react';
import { getEventTransfer } 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 Html from 'slate-html-serializer';
import Md from 'slate-md-serializer'; import Md from 'slate-md-serializer';
import Plain from 'slate-plain-serializer'; import Plain from 'slate-plain-serializer';
import PlainWithPillsSerializer from "../../../autocomplete/PlainWithPillsSerializer"; 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 classNames from 'classnames';
import Promise from 'bluebird'; import Promise from 'bluebird';
@ -54,7 +51,7 @@ import Markdown from '../../../Markdown';
import ComposerHistoryManager from '../../../ComposerHistoryManager'; import ComposerHistoryManager from '../../../ComposerHistoryManager';
import MessageComposerStore from '../../../stores/MessageComposerStore'; 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'); const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g');
import {asciiRegexp, unicodeRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort, toShort} from 'emojione'; 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 * The textInput part of the MessageComposer
*/ */
@ -146,29 +152,18 @@ export default class MessageComposerInput extends React.Component {
Analytics.setRichtextMode(isRichTextEnabled); 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(); 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.plainWithMdPills = new PlainWithPillsSerializer({ pillFormat: 'md' });
this.plainWithIdPills = new PlainWithPillsSerializer({ pillFormat: 'id' }); this.plainWithIdPills = new PlainWithPillsSerializer({ pillFormat: 'id' });
this.plainWithPlainPills = new PlainWithPillsSerializer({ pillFormat: 'plain' }); this.plainWithPlainPills = new PlainWithPillsSerializer({ pillFormat: 'plain' });
@ -176,18 +171,35 @@ export default class MessageComposerInput extends React.Component {
this.md = new Md({ this.md = new Md({
rules: [ rules: [
{ {
// if serialize returns undefined it falls through to the default hardcoded
// serialization rules
serialize: (obj, children) => { serialize: (obj, children) => {
if (obj.object === 'inline') { if (obj.object !== 'inline') return;
switch (obj.type) { switch (obj.type) {
case 'pill': case 'pill':
return `[${ obj.data.get('completion') }](${ obj.data.get('href') })`; return `[${ obj.data.get('completion') }](${ obj.data.get('href') })`;
case 'emoji': case 'emoji':
return obj.data.get('emojiUnicode'); 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({ this.html = new Html({
@ -278,20 +290,46 @@ export default class MessageComposerInput extends React.Component {
] ]
}); });
this.suppressAutoComplete = false; const savedState = MessageComposerStore.getEditorState(this.props.room.roomId);
this.direction = ''; 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: * "Does the right thing" to create an Editor value, based on:
* - whether we've got rich text mode enabled * - whether we've got rich text mode enabled
* - contentState was passed in * - 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 (editorState instanceof Value) {
if (wantRichText && !wasRichText) {
return this.mdToRichEditorState(editorState);
}
if (wasRichText && !wantRichText) {
return this.richToMdEditorState(editorState);
}
return editorState; return editorState;
} } else {
else {
// ...or create a new one. // ...or create a new one.
return Plain.deserialize('', { defaultBlock: DEFAULT_NODE }); return Plain.deserialize('', { defaultBlock: DEFAULT_NODE });
} }
@ -299,7 +337,7 @@ export default class MessageComposerInput extends React.Component {
componentDidMount() { componentDidMount() {
this.dispatcherRef = dis.register(this.onAction); 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() { 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? // If so, what should be the format, and how do we differentiate it from replies?
const quote = Block.create('block-quote'); const quote = Block.create('block-quote');
if (this.state.isRichTextEnabled) { if (this.state.isRichTextEnabled) {
let change = editorState.change(); let change = editorState.change();
if (editorState.anchorText.text === '' && editorState.anchorBlock.nodes.size === 1) { if (editorState.anchorText.text === '' && editorState.anchorBlock.nodes.size === 1) {
// replace the current block rather than split the block // replace the current block rather than split the block
@ -360,7 +398,6 @@ export default class MessageComposerInput extends React.Component {
let fragmentChange = fragment.change(); let fragmentChange = fragment.change();
fragmentChange.moveToRangeOf(fragment.document) fragmentChange.moveToRangeOf(fragment.document)
.wrapBlock(quote); .wrapBlock(quote);
//.setBlocks('block-quote');
// FIXME: handle pills and use commonmark rather than md-serialize // FIXME: handle pills and use commonmark rather than md-serialize
const md = this.md.serialize(fragmentChange.value); 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; let editorState = change.value;
if (this.direction !== '') { if (this.direction !== '') {
const focusedNode = editorState.focusInline || editorState.focusText; const focusedNode = editorState.focusInline || editorState.focusText;
if (focusedNode.isVoid) { if (focusedNode.isVoid) {
// XXX: does this work in RTL?
const edge = this.direction === 'Previous' ? 'End' : 'Start';
if (editorState.isCollapsed) { if (editorState.isCollapsed) {
change = change[`collapseToEndOf${ this.direction }Text`](); change = change[`collapseTo${ edge }Of${ this.direction }Text`]();
} } else {
else {
const block = this.direction === 'Previous' ? editorState.previousText : editorState.nextText; const block = this.direction === 'Previous' ? editorState.previousText : editorState.nextText;
if (block) { if (block) {
change = change.moveFocusToEndOf(block) change = change[`moveFocusTo${ edge }Of`](block);
} }
} }
editorState = change.value; editorState = change.value;
} }
} }
// when selection changes hide the autocomplete
if (!rangeEquals(this.state.editorState.selection, editorState.selection)) {
this.autocomplete.hide();
}
if (!editorState.document.isEmpty) { if (!editorState.document.isEmpty) {
this.onTypingActivity(); this.onTypingActivity();
} else { } else {
this.onFinishedTyping(); this.onFinishedTyping();
} }
/*
// XXX: what was this ever doing?
if (!state.hasOwnProperty('originalEditorState')) {
state.originalEditorState = null;
}
*/
if (editorState.startText !== null) { if (editorState.startText !== null) {
const text = editorState.startText.text; const text = editorState.startText.text;
const currentStartOffset = editorState.startOffset; const currentStartOffset = editorState.startOffset;
@ -501,9 +536,7 @@ export default class MessageComposerInput extends React.Component {
} }
// emojioneify any emoji // emojioneify any emoji
editorState.document.getTexts().forEach(node => {
// XXX: is getTextsAsArray a private API?
editorState.document.getTextsAsArray().forEach(node => {
if (node.text !== '' && HtmlUtils.containsEmoji(node.text)) { if (node.text !== '' && HtmlUtils.containsEmoji(node.text)) {
let match; let match;
while ((match = EMOJI_REGEX.exec(node.text)) !== null) { while ((match = EMOJI_REGEX.exec(node.text)) !== null) {
@ -535,36 +568,6 @@ export default class MessageComposerInput extends React.Component {
editorState = change.value; 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) { if (this.props.onInputStateChanged && editorState.blocks.size > 0) {
let blockType = editorState.blocks.first().type; let blockType = editorState.blocks.first().type;
// console.log("onInputStateChanged; current block type is " + blockType + " and marks are " + editorState.activeMarks); // 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({ dis.dispatch({
action: 'editor_state', action: 'editor_state',
room_id: this.props.room.roomId, room_id: this.props.room.roomId,
rich_text: this.state.isRichTextEnabled,
editor_state: editorState, editor_state: editorState,
}); });
/* Since a modification was made, set originalEditorState to null, since newState is now our original */
this.setState({ this.setState({
editorState, editorState,
originalEditorState: originalEditorState || null originalEditorState: originalEditorState || null
@ -672,7 +675,7 @@ export default class MessageComposerInput extends React.Component {
hasMark = type => { hasMark = type => {
const { editorState } = this.state 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 => { hasBlock = type => {
const { editorState } = this.state 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; this.suppressAutoComplete = false;
@ -702,22 +705,6 @@ export default class MessageComposerInput extends React.Component {
this.direction = ''; 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) { switch (ev.keyCode) {
case KeyCode.ENTER: case KeyCode.ENTER:
return this.handleReturn(ev, change); return this.handleReturn(ev, change);
@ -731,21 +718,53 @@ export default class MessageComposerInput extends React.Component {
return this.onTab(ev); return this.onTab(ev);
case KeyCode.ESCAPE: case KeyCode.ESCAPE:
return this.onEscape(ev); return this.onEscape(ev);
default: case KeyCode.SPACE:
// don't intercept it return this.onSpace(ev, change);
return; }
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 => { onSpace = (ev: KeyboardEvent, change: Change): Change => {
if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.ctrlKey || ev.shiftKey) { if (ev.metaKey || ev.altKey || ev.shiftKey || ev.ctrlKey) {
return; 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) { if (this.state.isRichTextEnabled) {
// let backspace exit lists // let backspace exit lists
const isList = this.hasBlock('list-item'); const isList = this.hasBlock('list-item');
const { editorState } = this.state;
if (isList && editorState.anchorOffset == 0) { if (isList && editorState.anchorOffset == 0) {
change change
@ -805,7 +824,7 @@ export default class MessageComposerInput extends React.Component {
// Handle the extra wrapping required for list buttons. // Handle the extra wrapping required for list buttons.
const isList = this.hasBlock('list-item'); const isList = this.hasBlock('list-item');
const isType = editorState.blocks.some(block => { 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) { if (isList && isType) {
@ -816,7 +835,7 @@ export default class MessageComposerInput extends React.Component {
} else if (isList) { } else if (isList) {
change change
.unwrapBlock( .unwrapBlock(
type == 'bulleted-list' ? 'numbered-list' : 'bulleted-list' type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list'
) )
.wrapBlock(type); .wrapBlock(type);
} else { } else {
@ -986,7 +1005,7 @@ export default class MessageComposerInput extends React.Component {
let contentHTML; let contentHTML;
// only look for commands if the first block contains simple unformatted text // 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; let cmd, commandText;
const firstChild = editorState.document.nodes.get(0); const firstChild = editorState.document.nodes.get(0);
const firstGrandChild = firstChild && firstChild.nodes.get(0); const firstGrandChild = firstChild && firstChild.nodes.get(0);
@ -995,7 +1014,7 @@ export default class MessageComposerInput extends React.Component {
firstGrandChild.text[0] === '/') firstGrandChild.text[0] === '/')
{ {
commandText = this.plainWithIdPills.serialize(editorState); commandText = this.plainWithIdPills.serialize(editorState);
cmd = SlashCommands.processInput(this.props.room.roomId, commandText); cmd = processCommandInput(this.props.room.roomId, commandText);
} }
if (cmd) { if (cmd) {
@ -1067,8 +1086,8 @@ export default class MessageComposerInput extends React.Component {
// didn't contain any formatting in the first place... // didn't contain any formatting in the first place...
contentText = mdWithPills.toPlaintext(); contentText = mdWithPills.toPlaintext();
} else { } else {
// to avoid ugliness clients which can't parse HTML we don't send pills // to avoid ugliness on clients which ignore the HTML body we don't
// in the plaintext body. // send pills in the plaintext body.
contentText = this.plainWithPlainPills.serialize(editorState); contentText = this.plainWithPlainPills.serialize(editorState);
contentHTML = mdWithPills.toHTML(); contentHTML = mdWithPills.toHTML();
} }
@ -1147,41 +1166,18 @@ export default class MessageComposerInput extends React.Component {
// Select history only if we are not currently auto-completing // Select history only if we are not currently auto-completing
if (this.autocomplete.state.completionList.length === 0) { 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 // selection must be collapsed
// input box by just looking at the position of the plain old DOM selection. if (!selection.isCollapsed) return;
const selection = window.getSelection(); const document = this.state.editorState.document;
const range = selection.getRangeAt(0);
const cursorRect = range.getBoundingClientRect();
const editorNode = ReactDOM.findDOMNode(this.refs.editor); // and we must be at the edge of the document (up=start, down=end)
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;
if (up) { if (up) {
const scrollCorrection = editorNode.scrollTop; if (!selection.isAtStartOf(document)) return;
const distanceFromTop = cursorRect.top - editorRect.top + scrollCorrection; } else {
console.log(`Cursor distance from editor top is ${distanceFromTop}`); if (!selection.isAtEndOf(document)) return;
if (distanceFromTop < EDGE_THRESHOLD) {
navigateHistory = true;
}
} }
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); const selected = this.selectHistory(up);
if (selected) { if (selected) {
@ -1232,11 +1228,8 @@ export default class MessageComposerInput extends React.Component {
// Move selection to the end of the selected history // Move selection to the end of the selected history
const change = editorState.change().collapseToEndOf(editorState.document); const change = editorState.change().collapseToEndOf(editorState.document);
// XXX: should we be calling this.onChange(change) now? // We don't call this.onChange(change) now, as fixups on stuff like emoji
// Answer: yes, if we want it to do any of the fixups on stuff like emoji. // should already have been done and persisted in the history.
// however, this should already have been done and persisted in the history,
// so shouldn't be necessary.
editorState = change.value; editorState = change.value;
this.suppressAutoComplete = true; this.suppressAutoComplete = true;
@ -1339,6 +1332,8 @@ export default class MessageComposerInput extends React.Component {
.insertText(suffix) .insertText(suffix)
.focus(); .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; editorState = change.value;
this.onChange(change, activeEditorState); this.onChange(change, activeEditorState);
@ -1437,10 +1432,11 @@ export default class MessageComposerInput extends React.Component {
}; };
onFormatButtonClicked = (name, e) => { 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 // 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 // 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') { if (document.activeElement && document.activeElement.className !== 'mx_MessageComposer_editor') {
this.refs.editor.focus(); this.refs.editor.focus();
setTimeout(()=>{ setTimeout(()=>{

View file

@ -406,6 +406,14 @@
"Invited": "Invited", "Invited": "Invited",
"Filter room members": "Filter room members", "Filter room members": "Filter room members",
"%(userName)s (power %(powerLevelNumber)s)": "%(userName)s (power %(powerLevelNumber)s)", "%(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", "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.", "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", "Upload Files": "Upload Files",
@ -430,14 +438,6 @@
"Command error": "Command error", "Command error": "Command error",
"Unable to reply": "Unable to reply", "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.", "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 disabled": "Markdown is disabled",
"Markdown is enabled": "Markdown is enabled", "Markdown is enabled": "Markdown is enabled",
"No pinned messages.": "No pinned messages.", "No pinned messages.": "No pinned messages.",
@ -772,7 +772,6 @@
"Room directory": "Room directory", "Room directory": "Room directory",
"Start chat": "Start chat", "Start chat": "Start chat",
"And %(count)s more...|other": "And %(count)s more...", "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", "ex. @bob:example.com": "ex. @bob:example.com",
"Add User": "Add User", "Add User": "Add User",
"Matrix ID": "Matrix ID", "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"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with 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 dis from '../dispatcher';
import { Store } from 'flux/utils'; import { Store } from 'flux/utils';
import { Value } from 'slate';
const INITIAL_STATE = { const INITIAL_STATE = {
// a map of room_id to rich text editor composer state // a map of room_id to rich text editor composer state
@ -54,7 +55,10 @@ class MessageComposerStore extends Store {
_editorState(payload) { _editorState(payload) {
const editorStateMap = this._state.editorStateMap; 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)); localStorage.setItem('editor_state', JSON.stringify(editorStateMap));
this._setState({ this._setState({
editorStateMap: editorStateMap, editorStateMap: editorStateMap,
@ -62,7 +66,15 @@ class MessageComposerStore extends Store {
} }
getEditorState(roomId) { 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() { reset() {

View file

@ -10,7 +10,6 @@ const MessageComposerInput = sdk.getComponent('views.rooms.MessageComposerInput'
import MatrixClientPeg from '../../../../src/MatrixClientPeg'; import MatrixClientPeg from '../../../../src/MatrixClientPeg';
import RoomMember from 'matrix-js-sdk'; import RoomMember from 'matrix-js-sdk';
/*
function addTextToDraft(text) { function addTextToDraft(text) {
const components = document.getElementsByClassName('public-DraftEditor-content'); const components = document.getElementsByClassName('public-DraftEditor-content');
if (components && components.length) { 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, let parentDiv = null,
sandbox = null, sandbox = null,
client = 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].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>'); expect(spy.args[0][1].formatted_body).toEqual('<a href="https://some.lovely.url">Click here</a>');
}); });
}); });
*/