diff --git a/package.json b/package.json
index 85b68f659b..192cefdf3a 100644
--- a/package.json
+++ b/package.json
@@ -26,10 +26,9 @@
"dependencies": {
"browser-request": "^0.3.3",
"classnames": "^2.1.2",
- "draft-js": "^0.7.0",
- "draft-js-export-html": "^0.2.2",
+ "draft-js": "^0.8.1",
+ "draft-js-export-html": "^0.4.0",
"draft-js-export-markdown": "^0.2.0",
- "draft-js-import-markdown": "^0.1.6",
"emojione": "2.2.3",
"favico.js": "^0.3.10",
"filesize": "^3.1.2",
diff --git a/src/RichText.js b/src/RichText.js
index 7cd78a14c9..31d82ee349 100644
--- a/src/RichText.js
+++ b/src/RichText.js
@@ -14,64 +14,22 @@ import {
} from 'draft-js';
import * as sdk from './index';
import * as emojione from 'emojione';
-
-const BLOCK_RENDER_MAP = DefaultDraftBlockRenderMap.set('unstyled', {
- element: 'span',
- /*
- draft uses
by default which we don't really like, so we're using
- this is probably not a good idea since is not a block level element but
- we're trying to fix things in contentStateToHTML below
- */
-});
-
-const STYLES = {
- BOLD: 'strong',
- CODE: 'code',
- ITALIC: 'em',
- STRIKETHROUGH: 's',
- UNDERLINE: 'u',
-};
+import {stateToHTML} from 'draft-js-export-html';
const MARKDOWN_REGEX = {
LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g,
ITALIC: /([\*_])([\w\s]+?)\1/g,
BOLD: /([\*_])\1([\w\s]+?)\1\1/g,
+ HR: /(\n|^)((-|\*|_) *){3,}(\n|$)/g,
+ CODE: /`[^`]*`/g,
+ STRIKETHROUGH: /~{2}[^~]*~{2}/g,
};
const USERNAME_REGEX = /@\S+:\S+/g;
const ROOM_REGEX = /#\S+:\S+/g;
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g');
-export function contentStateToHTML(contentState: ContentState): string {
- return contentState.getBlockMap().map((block) => {
- let elem = BLOCK_RENDER_MAP.get(block.getType()).element;
- let content = [];
- block.findStyleRanges(
- () => true, // always return true => don't filter any ranges out
- (start, end) => {
- // map style names to elements
- let tags = block.getInlineStyleAt(start).map(style => STYLES[style]).filter(style => !!style);
- // combine them to get well-nested HTML
- let open = tags.map(tag => `<${tag}>`).join('');
- let close = tags.map(tag => `${tag}>`).reverse().join('');
- // and get the HTML representation of this styled range (this .substring() should never fail)
- let text = block.getText().substring(start, end);
- // http://shebang.brandonmintern.com/foolproof-html-escaping-in-javascript/
- let div = document.createElement('div');
- div.appendChild(document.createTextNode(text));
- let safeText = div.innerHTML;
- content.push(`${open}${safeText}${close}`);
- }
- );
-
- let result = `<${elem}>${content.join('')}${elem}>`;
-
- // dirty hack because we don't want block level tags by default, but breaks
- if (elem === 'span')
- result += '
';
- return result;
- }).join('');
-}
+export const contentStateToHTML = stateToHTML;
export function HTMLtoContentState(html: string): ContentState {
return ContentState.createFromBlockArray(convertFromHTML(html));
@@ -98,6 +56,19 @@ function unicodeToEmojiUri(str) {
return str;
}
+/**
+ * Utility function that looks for regex matches within a ContentBlock and invokes {callback} with (start, end)
+ * From https://facebook.github.io/draft-js/docs/advanced-topics-decorators.html
+ */
+function findWithRegex(regex, contentBlock: ContentBlock, callback: (start: number, end: number) => any) {
+ const text = contentBlock.getText();
+ let matchArr, start;
+ while ((matchArr = regex.exec(text)) !== null) {
+ start = matchArr.index;
+ callback(start, start + matchArr[0].length);
+ }
+}
+
// Workaround for https://github.com/facebook/draft-js/issues/414
let emojiDecorator = {
strategy: (contentBlock, callback) => {
@@ -151,7 +122,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator {
}
export function getScopedMDDecorators(scope: any): CompositeDecorator {
- let markdownDecorators = ['BOLD', 'ITALIC'].map(
+ let markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map(
(style) => ({
strategy: (contentBlock, callback) => {
return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback);
@@ -178,19 +149,6 @@ export function getScopedMDDecorators(scope: any): CompositeDecorator {
return markdownDecorators;
}
-/**
- * Utility function that looks for regex matches within a ContentBlock and invokes {callback} with (start, end)
- * From https://facebook.github.io/draft-js/docs/advanced-topics-decorators.html
- */
-function findWithRegex(regex, contentBlock: ContentBlock, callback: (start: number, end: number) => any) {
- const text = contentBlock.getText();
- let matchArr, start;
- while ((matchArr = regex.exec(text)) !== null) {
- start = matchArr.index;
- callback(start, start + matchArr[0].length);
- }
-}
-
/**
* Passes rangeToReplace to modifyFn and replaces it in contentState with the result.
*/
diff --git a/src/UserSettingsStore.js b/src/UserSettingsStore.js
index f4eb4f0d83..3e0c7127c1 100644
--- a/src/UserSettingsStore.js
+++ b/src/UserSettingsStore.js
@@ -130,9 +130,9 @@ module.exports = {
return event ? event.getContent() : {};
},
- getSyncedSetting: function(type) {
+ getSyncedSetting: function(type, defaultValue = null) {
var settings = this.getSyncedSettings();
- return settings[type];
+ return settings.hasOwnProperty(type) ? settings[type] : null;
},
setSyncedSetting: function(type, value) {
diff --git a/src/components/views/rooms/Autocomplete.js b/src/components/views/rooms/Autocomplete.js
index 4b2e23a8b8..9b8b55ab51 100644
--- a/src/components/views/rooms/Autocomplete.js
+++ b/src/components/views/rooms/Autocomplete.js
@@ -149,13 +149,13 @@ export default class Autocomplete extends React.Component {
{completionResult.provider.renderCompletions(completions)}
) : null;
- });
+ }).filter(completion => !!completion);
- return (
+ return renderedCompletions.length > 0 ? (
this.container = e}>
{renderedCompletions}
- );
+ ) : null;
}
}
diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js
index 7b84d394e0..fc80bf8a90 100644
--- a/src/components/views/rooms/MessageComposer.js
+++ b/src/components/views/rooms/MessageComposer.js
@@ -21,6 +21,7 @@ var Modal = require('../../../Modal');
var sdk = require('../../../index');
var dis = require('../../../dispatcher');
import Autocomplete from './Autocomplete';
+import classNames from 'classnames';
import UserSettingsStore from '../../../UserSettingsStore';
@@ -38,10 +39,20 @@ export default class MessageComposer extends React.Component {
this.onDownArrow = this.onDownArrow.bind(this);
this._tryComplete = this._tryComplete.bind(this);
this._onAutocompleteConfirm = this._onAutocompleteConfirm.bind(this);
+ this.onToggleFormattingClicked = this.onToggleFormattingClicked.bind(this);
+ this.onToggleMarkdownClicked = this.onToggleMarkdownClicked.bind(this);
+ this.onInputStateChanged = this.onInputStateChanged.bind(this);
this.state = {
autocompleteQuery: '',
selection: null,
+ inputState: {
+ style: [],
+ blockType: null,
+ isRichtextEnabled: UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true),
+ wordCount: 0,
+ },
+ showFormatting: UserSettingsStore.getSyncedSetting('MessageComposer.showFormatting', false),
};
}
@@ -134,6 +145,10 @@ export default class MessageComposer extends React.Component {
});
}
+ onInputStateChanged(inputState) {
+ this.setState({inputState});
+ }
+
onUpArrow() {
return this.refs.autocomplete.onUpArrow();
}
@@ -155,6 +170,21 @@ export default class MessageComposer extends React.Component {
}
}
+ onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", event) {
+ event.preventDefault();
+ this.messageComposerInput.onFormatButtonClicked(name, event);
+ }
+
+ onToggleFormattingClicked() {
+ UserSettingsStore.setSyncedSetting('MessageComposer.showFormatting', !this.state.showFormatting);
+ this.setState({showFormatting: !this.state.showFormatting});
+ }
+
+ onToggleMarkdownClicked(e) {
+ e.preventDefault(); // don't steal focus from the editor!
+ this.messageComposerInput.enableRichtext(!this.state.inputState.isRichtextEnabled);
+ }
+
render() {
var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId);
var uploadInputStyle = {display: 'none'};
@@ -207,6 +237,16 @@ export default class MessageComposer extends React.Component {
);
+ const formattingButton = (
+
+ );
+
controls.push(
this.messageComposerInput = c}
@@ -217,7 +257,9 @@ export default class MessageComposer extends React.Component {
onUpArrow={this.onUpArrow}
onDownArrow={this.onDownArrow}
tabComplete={this.props.tabComplete} // used for old messagecomposerinput/tabcomplete
- onContentChanged={this.onInputContentChanged} />,
+ onContentChanged={this.onInputContentChanged}
+ onInputStateChanged={this.onInputStateChanged} />,
+ formattingButton,
uploadButton,
hangupButton,
callButton,
@@ -242,6 +284,26 @@ export default class MessageComposer extends React.Component {
;
}
+
+ const {style, blockType} = this.state.inputState;
+ const formatButtons = ["bold", "italic", "strike", "underline", "code", "quote", "bullet", "numbullet"].map(
+ name => {
+ const active = style.includes(name) || blockType === name;
+ const suffix = active ? '-o-n' : '';
+ const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name);
+ const disabled = !this.state.inputState.isRichtextEnabled && 'underline' === name;
+ const className = classNames("mx_MessageComposer_format_button", {
+ mx_MessageComposer_format_button_disabled: disabled,
+ });
+ return ;
+ },
+ );
+
return (
{autoComplete}
@@ -250,6 +312,22 @@ export default class MessageComposer extends React.Component {
{controls}
+ {UserSettingsStore.isFeatureEnabled('rich_text_editor') ?
+
+
+ {formatButtons}
+
+
+
+
+
: null
+ }
);
}
diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js
index 2d42b65246..1f5b303fe0 100644
--- a/src/components/views/rooms/MessageComposerInput.js
+++ b/src/components/views/rooms/MessageComposerInput.js
@@ -29,9 +29,11 @@ marked.setOptions({
import {Editor, EditorState, RichUtils, CompositeDecorator,
convertFromRaw, convertToRaw, Modifier, EditorChangeType,
- getDefaultKeyBinding, KeyBindingUtil, ContentState} from 'draft-js';
+ getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState} from 'draft-js';
import {stateToMarkdown} from 'draft-js-export-markdown';
+import classNames from 'classnames';
+import escape from 'lodash/escape';
import MatrixClientPeg from '../../../MatrixClientPeg';
import type {MatrixClient} from 'matrix-js-sdk/lib/matrix';
@@ -41,6 +43,7 @@ import sdk from '../../../index';
import dis from '../../../dispatcher';
import KeyCode from '../../../KeyCode';
+import UserSettingsStore from '../../../UserSettingsStore';
import * as RichText from '../../../RichText';
@@ -80,7 +83,6 @@ export default class MessageComposerInput extends React.Component {
constructor(props, context) {
super(props, context);
this.onAction = this.onAction.bind(this);
- this.onInputClick = this.onInputClick.bind(this);
this.handleReturn = this.handleReturn.bind(this);
this.handleKeyCommand = this.handleKeyCommand.bind(this);
this.setEditorState = this.setEditorState.bind(this);
@@ -88,15 +90,12 @@ export default class MessageComposerInput extends React.Component {
this.onDownArrow = this.onDownArrow.bind(this);
this.onTab = this.onTab.bind(this);
this.onConfirmAutocompletion = this.onConfirmAutocompletion.bind(this);
+ this.onMarkdownToggleClicked = this.onMarkdownToggleClicked.bind(this);
- let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled');
- if (isRichtextEnabled == null) {
- isRichtextEnabled = 'true';
- }
- isRichtextEnabled = isRichtextEnabled === 'true';
+ const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true);
this.state = {
- isRichtextEnabled: isRichtextEnabled,
+ isRichtextEnabled,
editorState: null,
};
@@ -236,8 +235,18 @@ export default class MessageComposerInput extends React.Component {
this.sentHistory.saveLastTextEntry();
}
+ componentWillUpdate(nextProps, nextState) {
+ // this is dirty, but moving all this state to MessageComposer is dirtier
+ if (this.props.onInputStateChanged && nextState !== this.state) {
+ const state = this.getSelectionInfo(nextState.editorState);
+ state.isRichtextEnabled = nextState.isRichtextEnabled;
+ this.props.onInputStateChanged(state);
+ }
+ }
+
onAction(payload) {
let editor = this.refs.editor;
+ let contentState = this.state.editorState.getCurrentContent();
switch (payload.action) {
case 'focus_composer':
@@ -246,35 +255,44 @@ export default class MessageComposerInput extends React.Component {
// TODO change this so we insert a complete user alias
- case 'insert_displayname':
- if (this.state.editorState.getCurrentContent().hasText()) {
- console.log(payload);
- let contentState = Modifier.replaceText(
- this.state.editorState.getCurrentContent(),
- this.state.editorState.getSelection(),
- payload.displayname
- );
- this.setState({
- editorState: EditorState.push(this.state.editorState, contentState, 'insert-characters'),
- });
+ case 'insert_displayname': {
+ contentState = Modifier.replaceText(
+ contentState,
+ this.state.editorState.getSelection(),
+ `${payload.displayname}: `
+ );
+ let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
+ editorState = EditorState.forceSelection(editorState, contentState.getSelectionAfter());
+ this.setEditorState(editorState);
+ editor.focus();
+ }
+ break;
+
+ case 'quote': {
+ let {event: {content: {body, formatted_body}}} = payload.event || {};
+ formatted_body = formatted_body || escape(body);
+ if (formatted_body) {
+ let content = RichText.HTMLtoContentState(`${formatted_body}
`);
+ if (!this.state.isRichtextEnabled) {
+ content = ContentState.createFromText(stateToMarkdown(content));
+ }
+
+ const blockMap = content.getBlockMap();
+ let startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey());
+ contentState = Modifier.splitBlock(contentState, startSelection);
+ startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey());
+ contentState = Modifier.replaceWithFragment(contentState,
+ startSelection,
+ blockMap);
+ startSelection = SelectionState.createEmpty(contentState.getFirstBlock().getKey());
+ if (this.state.isRichtextEnabled)
+ contentState = Modifier.setBlockType(contentState, startSelection, 'blockquote');
+ let editorState = EditorState.push(this.state.editorState, contentState, 'insert-characters');
+ this.setEditorState(editorState);
editor.focus();
}
- break;
- }
- }
-
- onKeyDown(ev) {
- if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) {
- var oldSelectionStart = this.refs.textarea.selectionStart;
- // Remember the keyCode because React will recycle the synthetic event
- var keyCode = ev.keyCode;
- // set a callback so we can see if the cursor position changes as
- // a result of this event. If it doesn't, we cycle history.
- setTimeout(() => {
- if (this.refs.textarea.selectionStart == oldSelectionStart) {
- this.sentHistory.next(keyCode === KeyCode.UP ? 1 : -1);
- }
- }, 0);
+ }
+ break;
}
}
@@ -344,13 +362,10 @@ export default class MessageComposerInput extends React.Component {
}
}
- onInputClick(ev) {
- this.refs.editor.focus();
- }
- setEditorState(editorState: EditorState) {
+ setEditorState(editorState: EditorState, cb = () => null) {
editorState = RichText.attachImmutableEntitiesToEmoji(editorState);
- this.setState({editorState});
+ this.setState({editorState}, cb);
if (editorState.getCurrentContent().hasText()) {
this.onTypingActivity();
@@ -359,27 +374,34 @@ export default class MessageComposerInput extends React.Component {
}
if (this.props.onContentChanged) {
- this.props.onContentChanged(editorState.getCurrentContent().getPlainText(),
- RichText.selectionStateToTextOffsets(editorState.getSelection(),
- editorState.getCurrentContent().getBlocksAsArray()));
+ const textContent = editorState.getCurrentContent().getPlainText();
+ const selection = RichText.selectionStateToTextOffsets(editorState.getSelection(),
+ editorState.getCurrentContent().getBlocksAsArray());
+
+ this.props.onContentChanged(textContent, selection);
}
}
enableRichtext(enabled: boolean) {
+ let contentState = null;
if (enabled) {
- let html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText());
- this.setEditorState(this.createEditorState(enabled, RichText.HTMLtoContentState(html)));
+ const html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText());
+ contentState = RichText.HTMLtoContentState(html);
} else {
- let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()),
- contentState = ContentState.createFromText(markdown);
- this.setEditorState(this.createEditorState(enabled, contentState));
+ let markdown = stateToMarkdown(this.state.editorState.getCurrentContent());
+ if (markdown[markdown.length - 1] === '\n') {
+ markdown = markdown.substring(0, markdown.length - 1); // stateToMarkdown tacks on an extra newline (?!?)
+ }
+ contentState = ContentState.createFromText(markdown);
}
- window.localStorage.setItem('mx_editor_rte_enabled', enabled);
-
- this.setState({
- isRichtextEnabled: enabled
+ this.setEditorState(this.createEditorState(enabled, contentState), () => {
+ this.setState({
+ isRichtextEnabled: enabled,
+ });
});
+
+ UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled);
}
handleKeyCommand(command: string): boolean {
@@ -391,7 +413,17 @@ export default class MessageComposerInput extends React.Component {
let newState: ?EditorState = null;
// Draft handles rich text mode commands by default but we need to do it ourselves for Markdown.
- if (!this.state.isRichtextEnabled) {
+ if (this.state.isRichtextEnabled) {
+ // These are block types, not handled by RichUtils by default.
+ const blockCommands = ['code-block', 'blockquote', 'unordered-list-item', 'ordered-list-item'];
+
+ if (blockCommands.includes(command)) {
+ this.setEditorState(RichUtils.toggleBlockType(this.state.editorState, command));
+ } else if (command === 'strike') {
+ // this is the only inline style not handled by Draft by default
+ this.setEditorState(RichUtils.toggleInlineStyle(this.state.editorState, 'STRIKETHROUGH'));
+ }
+ } else {
let contentState = this.state.editorState.getCurrentContent(),
selection = this.state.editorState.getSelection();
@@ -399,7 +431,11 @@ export default class MessageComposerInput extends React.Component {
bold: text => `**${text}**`,
italic: text => `*${text}*`,
underline: text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug*
+ strike: text => `~~${text}~~`,
code: text => `\`${text}\``,
+ blockquote: text => text.split('\n').map(line => `> ${line}\n`).join(''),
+ 'unordered-list-item': text => text.split('\n').map(line => `- ${line}\n`).join(''),
+ 'ordered-list-item': text => text.split('\n').map((line, i) => `${i+1}. ${line}\n`).join(''),
}[command];
if (modifyFn) {
@@ -418,12 +454,14 @@ export default class MessageComposerInput extends React.Component {
this.setEditorState(newState);
return true;
}
+
return false;
}
handleReturn(ev) {
if (ev.shiftKey) {
- return false;
+ this.setEditorState(RichUtils.insertSoftNewline(this.state.editorState));
+ return true;
}
const contentState = this.state.editorState.getCurrentContent();
@@ -464,7 +502,7 @@ export default class MessageComposerInput extends React.Component {
return true;
}
- if(this.state.isRichtextEnabled) {
+ if (this.state.isRichtextEnabled) {
contentHTML = RichText.contentStateToHTML(contentState);
} else {
contentHTML = mdownToHtml(contentText);
@@ -536,20 +574,91 @@ export default class MessageComposerInput extends React.Component {
setTimeout(() => this.refs.editor.focus(), 50);
}
- render() {
- let className = "mx_MessageComposer_input";
+ onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", e) {
+ e.preventDefault(); // don't steal focus from the editor!
+ const command = {
+ code: 'code-block',
+ quote: 'blockquote',
+ bullet: 'unordered-list-item',
+ numbullet: 'ordered-list-item',
+ }[name] || name;
+ this.handleKeyCommand(command);
+ }
- if (this.state.isRichtextEnabled) {
- className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode
+ /* returns inline style and block type of current SelectionState so MessageComposer can render formatting
+ buttons. */
+ getSelectionInfo(editorState: EditorState) {
+ const styleName = {
+ BOLD: 'bold',
+ ITALIC: 'italic',
+ STRIKETHROUGH: 'strike',
+ UNDERLINE: 'underline',
+ };
+
+ const originalStyle = editorState.getCurrentInlineStyle().toArray();
+ const style = originalStyle
+ .map(style => styleName[style] || null)
+ .filter(styleName => !!styleName);
+
+ const blockName = {
+ 'code-block': 'code',
+ blockquote: 'quote',
+ 'unordered-list-item': 'bullet',
+ 'ordered-list-item': 'numbullet',
+ };
+ const originalBlockType = editorState.getCurrentContent()
+ .getBlockForKey(editorState.getSelection().getStartKey())
+ .getType();
+ const blockType = blockName[originalBlockType] || null;
+
+ return {
+ style,
+ blockType,
+ };
+ }
+
+ onMarkdownToggleClicked(e) {
+ e.preventDefault(); // don't steal focus from the editor!
+ this.handleKeyCommand('toggle-mode');
+ }
+
+ getBlockStyle(block: ContentBlock): ?string {
+ if (block.getType() === 'strikethrough') {
+ return 'mx_Markdown_STRIKETHROUGH';
}
+ return null;
+ }
+
+ render() {
+ const {editorState} = this.state;
+
+ // From https://github.com/facebook/draft-js/blob/master/examples/rich/rich.html#L92
+ // If the user changes block type before entering any text, we can
+ // either style the placeholder or hide it.
+ let hidePlaceholder = false;
+ const contentState = editorState.getCurrentContent();
+ if (!contentState.hasText()) {
+ if (contentState.getBlockMap().first().getType() !== 'unstyled') {
+ hidePlaceholder = true;
+ }
+ }
+
+ const className = classNames('mx_MessageComposer_input', {
+ mx_MessageComposer_input_empty: hidePlaceholder,
+ });
+
return (
-
+
+