From 71251293e44001c7a2b5b0d35740e63fd68a4dcc Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Sun, 4 Sep 2016 21:03:40 +0530 Subject: [PATCH 1/7] RTE formatbar (wip) Fixes vector-im/vector-web#2024 --- package.json | 5 +- src/RichText.js | 75 ++++------------ src/components/views/rooms/MessageComposer.js | 34 +++++++- .../views/rooms/MessageComposerInput.js | 86 +++++++++++++++++-- 4 files changed, 129 insertions(+), 71 deletions(-) diff --git a/package.json b/package.json index d1b9122a62..f8f0106a32 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..073d873945 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -14,23 +14,7 @@ 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, @@ -42,36 +26,7 @@ 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 => ``).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('')}`; - - // 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 +53,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) => { @@ -178,19 +146,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/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 7b84d394e0..3d01052ccf 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -42,6 +42,10 @@ export default class MessageComposer extends React.Component { this.state = { autocompleteQuery: '', selection: null, + selectionInfo: { + style: [], + blockType: null, + }, }; } @@ -127,10 +131,11 @@ export default class MessageComposer extends React.Component { }); } - onInputContentChanged(content: string, selection: {start: number, end: number}) { + onInputContentChanged(content: string, selection: {start: number, end: number}, selectionInfo) { this.setState({ autocompleteQuery: content, selection, + selectionInfo, }); } @@ -155,6 +160,10 @@ export default class MessageComposer extends React.Component { } } + onFormatButtonClicked(name: "bold" | "italic" | "strike" | "quote" | "bullet" | "numbullet", event) { + this.messageComposerInput.onFormatButtonClicked(name, event); + } + render() { var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId); var uploadInputStyle = {display: 'none'}; @@ -207,6 +216,12 @@ export default class MessageComposer extends React.Component {
); + const formattingButton = ( + + ); + controls.push( this.messageComposerInput = c} @@ -218,6 +233,7 @@ export default class MessageComposer extends React.Component { onDownArrow={this.onDownArrow} tabComplete={this.props.tabComplete} // used for old messagecomposerinput/tabcomplete onContentChanged={this.onInputContentChanged} />, + formattingButton, uploadButton, hangupButton, callButton, @@ -242,6 +258,17 @@ export default class MessageComposer extends React.Component { ; } + + const {style, blockType} = this.state.selectionInfo; + const formatButtons = ["bold", "italic", "strike", "quote", "bullet", "numbullet"].map( + name => { + const active = style.includes(name) || blockType === name; + const suffix = active ? '-o-n' : ''; + const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name); + return ; + }, + ); + return (
{autoComplete} @@ -250,6 +277,11 @@ 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..aebb1855f3 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -32,6 +32,7 @@ import {Editor, EditorState, RichUtils, CompositeDecorator, getDefaultKeyBinding, KeyBindingUtil, ContentState} from 'draft-js'; import {stateToMarkdown} from 'draft-js-export-markdown'; +import classNames from 'classnames'; import MatrixClientPeg from '../../../MatrixClientPeg'; import type {MatrixClient} from 'matrix-js-sdk/lib/matrix'; @@ -359,9 +360,12 @@ 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()); + const selectionInfo = this.getSelectionInfo(editorState); + + this.props.onContentChanged(textContent, selection, selectionInfo); } } @@ -418,6 +422,7 @@ export default class MessageComposerInput extends React.Component { this.setEditorState(newState); return true; } + return false; } @@ -536,12 +541,79 @@ export default class MessageComposerInput extends React.Component { setTimeout(() => this.refs.editor.focus(), 50); } - render() { - let className = "mx_MessageComposer_input"; + onFormatButtonClicked(name: "bold" | "italic" | "strike" | "quote" | "bullet" | "numbullet", e) { + const style = { + bold: 'BOLD', + italic: 'ITALIC', + strike: 'STRIKETHROUGH', + }[name]; - if (this.state.isRichtextEnabled) { - className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode + if (style) { + e.preventDefault(); + this.setEditorState(RichUtils.toggleInlineStyle(this.state.editorState, style)); + } else { + const blockType = { + quote: 'blockquote', + bullet: 'unordered-list-item', + numbullet: 'ordered-list-item', + }[name]; + + if (blockType) { + e.preventDefault(); + this.setEditorState(RichUtils.toggleBlockType(this.state.editorState, blockType)); + } else { + console.error(`Unknown formatting style "${name}", ignoring.`); + } } + } + + /* 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', + }; + + const originalStyle = editorState.getCurrentInlineStyle().toArray(); + const style = originalStyle + .map(style => styleName[style] || null) + .filter(styleName => !!styleName); + + const blockName = { + 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, + }; + } + + 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 (
Date: Mon, 5 Sep 2016 17:38:53 +0530 Subject: [PATCH 2/7] Formatting toggle, markdown indicator, quoting Fixes vector-im/vector-web#1825 --- src/components/views/rooms/MessageComposer.js | 44 ++++++++-- .../views/rooms/MessageComposerInput.js | 83 ++++++++++++++----- 2 files changed, 97 insertions(+), 30 deletions(-) diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 3d01052ccf..dbc970511a 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -38,14 +38,19 @@ 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, - selectionInfo: { + inputState: { style: [], blockType: null, + isRichtextEnabled: true, }, + showFormatting: false, }; } @@ -131,14 +136,17 @@ export default class MessageComposer extends React.Component { }); } - onInputContentChanged(content: string, selection: {start: number, end: number}, selectionInfo) { + onInputContentChanged(content: string, selection: {start: number, end: number}) { this.setState({ autocompleteQuery: content, selection, - selectionInfo, }); } + onInputStateChanged(inputState) { + this.setState({inputState}); + } + onUpArrow() { return this.refs.autocomplete.onUpArrow(); } @@ -161,9 +169,18 @@ export default class MessageComposer extends React.Component { } onFormatButtonClicked(name: "bold" | "italic" | "strike" | "quote" | "bullet" | "numbullet", event) { + event.preventDefault(); this.messageComposerInput.onFormatButtonClicked(name, event); } + onToggleFormattingClicked() { + this.setState({showFormatting: !this.state.showFormatting}); + } + + onToggleMarkdownClicked() { + this.messageComposerInput.enableRichtext(!this.state.inputState.isRichtextEnabled); + } + render() { var me = this.props.room.getMember(MatrixClientPeg.get().credentials.userId); var uploadInputStyle = {display: 'none'}; @@ -217,8 +234,11 @@ export default class MessageComposer extends React.Component { ); const formattingButton = ( - ); @@ -232,7 +252,8 @@ 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, @@ -259,7 +280,7 @@ export default class MessageComposer extends React.Component { } - const {style, blockType} = this.state.selectionInfo; + const {style, blockType} = this.state.inputState; const formatButtons = ["bold", "italic", "strike", "quote", "bullet", "numbullet"].map( name => { const active = style.includes(name) || blockType === name; @@ -278,8 +299,17 @@ export default class MessageComposer extends React.Component {
{UserSettingsStore.isFeatureEnabled('rich_text_editor') ? -
+
{formatButtons} +
+ +
: null }
diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index aebb1855f3..49b0706499 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -29,10 +29,11 @@ marked.setOptions({ import {Editor, EditorState, RichUtils, CompositeDecorator, convertFromRaw, convertToRaw, Modifier, EditorChangeType, - getDefaultKeyBinding, KeyBindingUtil, ContentState} from 'draft-js'; + getDefaultKeyBinding, KeyBindingUtil, ContentState, 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'; @@ -42,6 +43,7 @@ import sdk from '../../../index'; import dis from '../../../dispatcher'; import KeyCode from '../../../KeyCode'; +import UserSettingsStore from '../../../UserSettingsStore'; import * as RichText from '../../../RichText'; @@ -90,14 +92,10 @@ export default class MessageComposerInput extends React.Component { this.onTab = this.onTab.bind(this); this.onConfirmAutocompletion = this.onConfirmAutocompletion.bind(this); - let isRichtextEnabled = window.localStorage.getItem('mx_editor_rte_enabled'); - if (isRichtextEnabled == null) { - isRichtextEnabled = 'true'; - } - isRichtextEnabled = isRichtextEnabled === 'true'; + const isRichtextEnabled = UserSettingsStore.isFeatureEnabled('rich_text_editor'); this.state = { - isRichtextEnabled: isRichtextEnabled, + isRichtextEnabled, editorState: null, }; @@ -237,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': @@ -247,20 +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; + } + break; } } @@ -363,9 +395,8 @@ export default class MessageComposerInput extends React.Component { const textContent = editorState.getCurrentContent().getPlainText(); const selection = RichText.selectionStateToTextOffsets(editorState.getSelection(), editorState.getCurrentContent().getBlocksAsArray()); - const selectionInfo = this.getSelectionInfo(editorState); - this.props.onContentChanged(textContent, selection, selectionInfo); + this.props.onContentChanged(textContent, selection); } } @@ -428,7 +459,8 @@ export default class MessageComposerInput extends React.Component { handleReturn(ev) { if (ev.shiftKey) { - return false; + this.setEditorState(RichUtils.insertSoftNewline(this.state.editorState)); + return true; } const contentState = this.state.editorState.getCurrentContent(); @@ -469,7 +501,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); @@ -618,6 +650,9 @@ export default class MessageComposerInput extends React.Component { return (
+ Date: Wed, 7 Sep 2016 22:52:14 +0530 Subject: [PATCH 3/7] RTE format bar enhancements --- src/RichText.js | 4 +- src/UserSettingsStore.js | 4 +- src/components/views/rooms/MessageComposer.js | 54 +++++++++----- .../views/rooms/MessageComposerInput.js | 70 ++++++++++--------- 4 files changed, 79 insertions(+), 53 deletions(-) diff --git a/src/RichText.js b/src/RichText.js index 073d873945..aebd6f5765 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -20,6 +20,8 @@ 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, }; const USERNAME_REGEX = /@\S+:\S+/g; @@ -119,7 +121,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator { } export function getScopedMDDecorators(scope: any): CompositeDecorator { - let markdownDecorators = ['BOLD', 'ITALIC'].map( + let markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE'].map( (style) => ({ strategy: (contentBlock, callback) => { return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback); 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/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index dbc970511a..971024eb57 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'; @@ -48,9 +49,10 @@ export default class MessageComposer extends React.Component { inputState: { style: [], blockType: null, - isRichtextEnabled: true, + isRichtextEnabled: UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true), + wordCount: 0, }, - showFormatting: false, + showFormatting: UserSettingsStore.getSyncedSetting('MessageComposer.showFormatting', false), }; } @@ -168,17 +170,20 @@ export default class MessageComposer extends React.Component { } } - onFormatButtonClicked(name: "bold" | "italic" | "strike" | "quote" | "bullet" | "numbullet", event) { + 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}); + this.messageComposerInput.focus(); } onToggleMarkdownClicked() { this.messageComposerInput.enableRichtext(!this.state.inputState.isRichtextEnabled); + this.messageComposerInput.focus(); } render() { @@ -238,7 +243,8 @@ export default class MessageComposer extends React.Component { title="Show Text Formatting Toolbar" src="img/button-text-formatting.svg" onClick={this.onToggleFormattingClicked} - style={{visibility: this.state.showFormatting ? 'hidden' : 'visible'}} + style={{visibility: this.state.showFormatting || + !UserSettingsStore.isFeatureEnabled('rich_text_editor') ? 'hidden' : 'visible'}} key="controls_formatting" /> ); @@ -281,12 +287,21 @@ export default class MessageComposer extends React.Component { const {style, blockType} = this.state.inputState; - const formatButtons = ["bold", "italic", "strike", "quote", "bullet", "numbullet"].map( + 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); - return ; + const disabled = !this.state.inputState.isRichtextEnabled && ['strike', 'underline'].includes(name); + const className = classNames("mx_MessageComposer_format_button", { + mx_MessageComposer_format_button_disabled: disabled, + }); + return ; }, ); @@ -299,18 +314,21 @@ export default class MessageComposer extends React.Component {
{UserSettingsStore.isFeatureEnabled('rich_text_editor') ? -
- {formatButtons} -
- - -
: null +
+
+ {formatButtons} +
+ {this.state.inputState.wordCount} + + +
+
: null } ); diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 49b0706499..32f4497326 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -83,7 +83,7 @@ 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.focus = this.focus.bind(this); this.handleReturn = this.handleReturn.bind(this); this.handleKeyCommand = this.handleKeyCommand.bind(this); this.setEditorState = this.setEditorState.bind(this); @@ -91,8 +91,9 @@ 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); - const isRichtextEnabled = UserSettingsStore.isFeatureEnabled('rich_text_editor'); + const isRichtextEnabled = UserSettingsStore.getSyncedSetting('MessageComposerInput.isRichTextEnabled', true); this.state = { isRichtextEnabled, @@ -240,6 +241,7 @@ export default class MessageComposerInput extends React.Component { if (this.props.onInputStateChanged && nextState !== this.state) { const state = this.getSelectionInfo(nextState.editorState); state.isRichtextEnabled = nextState.isRichtextEnabled; + state.wordCount = nextState.editorState.getCurrentContent().getPlainText().split(' ').filter(w => !!w).length; this.props.onInputStateChanged(state); } } @@ -377,7 +379,7 @@ export default class MessageComposerInput extends React.Component { } } - onInputClick(ev) { + focus(ev) { this.refs.editor.focus(); } @@ -410,11 +412,11 @@ export default class MessageComposerInput extends React.Component { this.setEditorState(this.createEditorState(enabled, contentState)); } - window.localStorage.setItem('mx_editor_rte_enabled', enabled); - this.setState({ - isRichtextEnabled: enabled + isRichtextEnabled: enabled, }); + + UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled); } handleKeyCommand(command: string): boolean { @@ -426,7 +428,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(); @@ -435,6 +447,9 @@ export default class MessageComposerInput extends React.Component { italic: text => `*${text}*`, underline: text => `_${text}_`, // there's actually no valid underline in Markdown, but *shrug* 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) { @@ -573,30 +588,14 @@ export default class MessageComposerInput extends React.Component { setTimeout(() => this.refs.editor.focus(), 50); } - onFormatButtonClicked(name: "bold" | "italic" | "strike" | "quote" | "bullet" | "numbullet", e) { - const style = { - bold: 'BOLD', - italic: 'ITALIC', - strike: 'STRIKETHROUGH', - }[name]; - - if (style) { - e.preventDefault(); - this.setEditorState(RichUtils.toggleInlineStyle(this.state.editorState, style)); - } else { - const blockType = { - quote: 'blockquote', - bullet: 'unordered-list-item', - numbullet: 'ordered-list-item', - }[name]; - - if (blockType) { - e.preventDefault(); - this.setEditorState(RichUtils.toggleBlockType(this.state.editorState, blockType)); - } else { - console.error(`Unknown formatting style "${name}", ignoring.`); - } - } + onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", e) { + const command = { + code: 'code-block', + quote: 'blockquote', + bullet: 'unordered-list-item', + numbullet: 'ordered-list-item', + }[name] || name; + this.handleKeyCommand(command); } /* returns inline style and block type of current SelectionState so MessageComposer can render formatting @@ -606,6 +605,7 @@ export default class MessageComposerInput extends React.Component { BOLD: 'bold', ITALIC: 'italic', STRIKETHROUGH: 'strike', + UNDERLINE: 'underline', }; const originalStyle = editorState.getCurrentInlineStyle().toArray(); @@ -614,6 +614,7 @@ export default class MessageComposerInput extends React.Component { .filter(styleName => !!styleName); const blockName = { + 'code-block': 'code', blockquote: 'quote', 'unordered-list-item': 'bullet', 'ordered-list-item': 'numbullet', @@ -629,6 +630,10 @@ export default class MessageComposerInput extends React.Component { }; } + onMarkdownToggleClicked() { + this.enableRichtext(!this.state.isRichtextEnabled); + } + render() { const {editorState} = this.state; @@ -649,8 +654,9 @@ export default class MessageComposerInput extends React.Component { return (
+ onClick={ this.focus }> Date: Wed, 7 Sep 2016 23:56:22 +0530 Subject: [PATCH 4/7] Fix Markdown conversion to not add extra \n Fixes vector-im/vector-web#2094 --- src/components/views/rooms/MessageComposerInput.js | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 32f4497326..2e8ca5c78a 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -404,11 +404,13 @@ export default class MessageComposerInput extends React.Component { enableRichtext(enabled: boolean) { 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()); + const contentState = RichText.HTMLtoContentState(html); + this.setEditorState(this.createEditorState(enabled, contentState)); } else { - let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()), - contentState = ContentState.createFromText(markdown); + let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()); + markdown = markdown.substring(0, markdown.length - 1); // stateToMarkdown tacks on an extra newline (?!?) + const contentState = ContentState.createFromText(markdown); this.setEditorState(this.createEditorState(enabled, contentState)); } From 8974442084b661814215dd432a9f8ff2808cf555 Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Thu, 8 Sep 2016 00:36:23 +0530 Subject: [PATCH 5/7] Hide empty autocomplete and remove word counter --- src/components/views/rooms/Autocomplete.js | 6 +++--- src/components/views/rooms/MessageComposer.js | 1 - src/components/views/rooms/MessageComposerInput.js | 1 - 3 files changed, 3 insertions(+), 5 deletions(-) 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 971024eb57..fdb6e4e169 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -318,7 +318,6 @@ export default class MessageComposer extends React.Component {
{formatButtons}
- {this.state.inputState.wordCount} !!w).length; this.props.onInputStateChanged(state); } } From c11232742bd45211541e93e91e91ab76ebba479f Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Thu, 8 Sep 2016 02:46:56 +0530 Subject: [PATCH 6/7] Fix focus and toggling issues in formatting bar --- src/RichText.js | 3 +- src/components/views/rooms/MessageComposer.js | 11 +++---- .../views/rooms/MessageComposerInput.js | 33 +++++++++---------- 3 files changed, 23 insertions(+), 24 deletions(-) diff --git a/src/RichText.js b/src/RichText.js index aebd6f5765..31d82ee349 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -22,6 +22,7 @@ const MARKDOWN_REGEX = { BOLD: /([\*_])\1([\w\s]+?)\1\1/g, HR: /(\n|^)((-|\*|_) *){3,}(\n|$)/g, CODE: /`[^`]*`/g, + STRIKETHROUGH: /~{2}[^~]*~{2}/g, }; const USERNAME_REGEX = /@\S+:\S+/g; @@ -121,7 +122,7 @@ export function getScopedRTDecorators(scope: any): CompositeDecorator { } export function getScopedMDDecorators(scope: any): CompositeDecorator { - let markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE'].map( + let markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map( (style) => ({ strategy: (contentBlock, callback) => { return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback); diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index fdb6e4e169..fc80bf8a90 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -178,12 +178,11 @@ export default class MessageComposer extends React.Component { onToggleFormattingClicked() { UserSettingsStore.setSyncedSetting('MessageComposer.showFormatting', !this.state.showFormatting); this.setState({showFormatting: !this.state.showFormatting}); - this.messageComposerInput.focus(); } - onToggleMarkdownClicked() { + onToggleMarkdownClicked(e) { + e.preventDefault(); // don't steal focus from the editor! this.messageComposerInput.enableRichtext(!this.state.inputState.isRichtextEnabled); - this.messageComposerInput.focus(); } render() { @@ -292,13 +291,13 @@ export default class MessageComposer extends React.Component { 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 && ['strike', 'underline'].includes(name); + const disabled = !this.state.inputState.isRichtextEnabled && 'underline' === name; const className = classNames("mx_MessageComposer_format_button", { mx_MessageComposer_format_button_disabled: disabled, }); return ; @@ -319,7 +318,7 @@ export default class MessageComposer extends React.Component { {formatButtons}
null) { editorState = RichText.attachImmutableEntitiesToEmoji(editorState); - this.setState({editorState}); + this.setState({editorState}, cb); if (editorState.getCurrentContent().hasText()) { this.onTypingActivity(); @@ -402,19 +398,20 @@ export default class MessageComposerInput extends React.Component { } enableRichtext(enabled: boolean) { + let contentState = null; if (enabled) { const html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText()); - const contentState = RichText.HTMLtoContentState(html); - this.setEditorState(this.createEditorState(enabled, contentState)); + contentState = RichText.HTMLtoContentState(html); } else { let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()); markdown = markdown.substring(0, markdown.length - 1); // stateToMarkdown tacks on an extra newline (?!?) - const contentState = ContentState.createFromText(markdown); - this.setEditorState(this.createEditorState(enabled, contentState)); + contentState = ContentState.createFromText(markdown); } - this.setState({ - isRichtextEnabled: enabled, + this.setEditorState(this.createEditorState(enabled, contentState), () => { + this.setState({ + isRichtextEnabled: enabled, + }); }); UserSettingsStore.setSyncedSetting('MessageComposerInput.isRichTextEnabled', enabled); @@ -447,6 +444,7 @@ 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(''), @@ -590,6 +588,7 @@ export default class MessageComposerInput extends React.Component { } 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', @@ -631,8 +630,9 @@ export default class MessageComposerInput extends React.Component { }; } - onMarkdownToggleClicked() { - this.enableRichtext(!this.state.isRichtextEnabled); + onMarkdownToggleClicked(e) { + e.preventDefault(); // don't steal focus from the editor! + this.handleKeyCommand('toggle-mode'); } render() { @@ -654,10 +654,9 @@ export default class MessageComposerInput extends React.Component { }); return ( -
+
Date: Thu, 8 Sep 2016 11:21:39 +0530 Subject: [PATCH 7/7] RTE: Remove dead code, fix styling --- .../views/rooms/MessageComposerInput.js | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 040afe7103..1f5b303fe0 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -29,7 +29,7 @@ marked.setOptions({ import {Editor, EditorState, RichUtils, CompositeDecorator, convertFromRaw, convertToRaw, Modifier, EditorChangeType, - getDefaultKeyBinding, KeyBindingUtil, ContentState, SelectionState} from 'draft-js'; + getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState} from 'draft-js'; import {stateToMarkdown} from 'draft-js-export-markdown'; import classNames from 'classnames'; @@ -296,21 +296,6 @@ export default class MessageComposerInput extends React.Component { } } - 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); - } - } - onTypingActivity() { this.isTyping = true; if (!this.userTypingTimer) { @@ -404,7 +389,9 @@ export default class MessageComposerInput extends React.Component { contentState = RichText.HTMLtoContentState(html); } else { let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()); - markdown = markdown.substring(0, markdown.length - 1); // stateToMarkdown tacks on an extra newline (?!?) + if (markdown[markdown.length - 1] === '\n') { + markdown = markdown.substring(0, markdown.length - 1); // stateToMarkdown tacks on an extra newline (?!?) + } contentState = ContentState.createFromText(markdown); } @@ -635,6 +622,14 @@ export default class MessageComposerInput extends React.Component { this.handleKeyCommand('toggle-mode'); } + getBlockStyle(block: ContentBlock): ?string { + if (block.getType() === 'strikethrough') { + return 'mx_Markdown_STRIKETHROUGH'; + } + + return null; + } + render() { const {editorState} = this.state; @@ -663,6 +658,7 @@ export default class MessageComposerInput extends React.Component { placeholder="Type a messageā€¦" editorState={this.state.editorState} onChange={this.setEditorState} + blockStyleFn={this.getBlockStyle} keyBindingFn={MessageComposerInput.getKeyBinding} handleKeyCommand={this.handleKeyCommand} handleReturn={this.handleReturn}