From e4217c3fb7238d0da09101f1e6fd75bd82a4712e Mon Sep 17 00:00:00 2001 From: Aviral Dasgupta Date: Sat, 11 Jun 2016 15:52:08 +0530 Subject: [PATCH] rte improvements, markdown mode --- package.json | 2 + src/RichText.js | 35 ++- .../views/rooms/MessageComposerInput.js | 282 +++++++++--------- 3 files changed, 168 insertions(+), 151 deletions(-) diff --git a/package.json b/package.json index 75b4b05c60..d3021fb1ac 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,8 @@ "classnames": "^2.1.2", "draft-js": "^0.7.0", "draft-js-export-html": "^0.2.2", + "draft-js-export-markdown": "^0.2.0", + "draft-js-import-markdown": "^0.1.6", "favico.js": "^0.3.10", "filesize": "^3.1.2", "flux": "^2.0.3", diff --git a/src/RichText.js b/src/RichText.js index d6723b9bd3..121efc871c 100644 --- a/src/RichText.js +++ b/src/RichText.js @@ -1,9 +1,8 @@ import {Editor, ContentState, convertFromHTML, DefaultDraftBlockRenderMap, DefaultDraftInlineStyle, CompositeDecorator} from 'draft-js'; -const ReactDOM = require('react-dom'); -var sdk = require('./index'); +import * as sdk from './index'; const BLOCK_RENDER_MAP = DefaultDraftBlockRenderMap.set('unstyled', { - element: 'p' // draft uses
by default which we don't really like + element: 'p' // draft uses
by default which we don't really like, so we're using

}); const styles = { @@ -14,21 +13,19 @@ const styles = { UNDERLINE: 'u' }; -export function contentStateToHTML(contentState:ContentState): String { - const elem = contentState.getBlockMap().map((block) => { - const elem = BLOCK_RENDER_MAP.get(block.getType()).element; - const content = []; - block.findStyleRanges(() => true, (s, e) => { - const tags = block.getInlineStyleAt(s).map(style => styles[style]); +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, (start, end) => { + const tags = block.getInlineStyleAt(start).map(style => styles[style]); const open = tags.map(tag => `<${tag}>`).join(''); const close = tags.map(tag => ``).reverse().join(''); - content.push(`${open}${block.getText().substring(s, e)}${close}`); + content.push(`${open}${block.getText().substring(start, end)}${close}`); }); return (`<${elem}>${content.join('')}`); }).join(''); - - return elem; } export function HTMLtoContentState(html:String): ContentState { @@ -38,6 +35,12 @@ export function HTMLtoContentState(html:String): ContentState { const USERNAME_REGEX = /@\S+:\S+/g; const ROOM_REGEX = /#\S+:\S+/g; +/** + * Returns a composite decorator which has access to provided scope. + * + * @param scope + * @returns {*} + */ export function getScopedDecorator(scope) { const MemberAvatar = sdk.getComponent('avatars.MemberAvatar'); @@ -46,17 +49,13 @@ export function getScopedDecorator(scope) { findWithRegex(USERNAME_REGEX, contentBlock, callback); }, component: (props) => { - console.log(props.children); - console.log(props.children[0].props.text); - const member = scope.room.getMember(props.children[0].props.text); - console.log(scope); - window.scope = scope; + let member = scope.room.getMember(props.children[0].props.text); let name = null; if(!!member) { name = member.name; } console.log(member); - const avatar = member ? : null; + let avatar = member ? : null; return {avatar} {props.children}; } }; diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index 129f048493..56046f0798 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -13,7 +13,7 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. */ -var React = require("react"); +import React from 'react'; var marked = require("marked"); marked.setOptions({ @@ -27,7 +27,11 @@ marked.setOptions({ smartypants: false }); -import {Editor, EditorState, RichUtils, CompositeDecorator} from 'draft-js'; +import {Editor, EditorState, RichUtils, CompositeDecorator, + convertFromRaw, convertToRaw, Modifier, EditorChangeType, + getDefaultKeyBinding, KeyBindingUtil, ContentState} from 'draft-js'; + +import {stateToMarkdown} from 'draft-js-export-markdown'; var MatrixClientPeg = require("../../../MatrixClientPeg"); var SlashCommands = require("../../../SlashCommands"); @@ -40,9 +44,20 @@ var KeyCode = require("../../../KeyCode"); import {contentStateToHTML, HTMLtoContentState, getScopedDecorator} from '../../../RichText'; -var TYPING_USER_TIMEOUT = 10000; -var TYPING_SERVER_TIMEOUT = 30000; -var MARKDOWN_ENABLED = true; +const TYPING_USER_TIMEOUT = 10000, TYPING_SERVER_TIMEOUT = 30000; + +function mdownToHtml(mdown) { + var html = marked(mdown) || ""; + html = html.trim(); + // strip start and end

tags else you get 'orrible spacing + if (html.indexOf("

") === 0) { + html = html.substring("

".length); + } + if (html.lastIndexOf("

") === (html.length - "

".length)) { + html = html.substring(0, html.length - "

".length); + } + return html; +} /* * The textInput part of the MessageComposer @@ -54,13 +69,38 @@ export default class MessageComposerInput extends React.Component { this.onInputClick = this.onInputClick.bind(this); this.state = { - editorState: EditorState.createEmpty(getScopedDecorator(this.props)) + isRichtextEnabled: true, + editorState: null }; + + // bit of a hack, but we need to do this here since createEditorState needs isRichtextEnabled + this.state.editorState = this.createEditorState(); + } + + static getKeyBinding(e: SyntheticKeyboardEvent): string { + // C-m => Toggles between rich text and markdown modes + if(e.keyCode == 77 && KeyBindingUtil.isCtrlKeyCommand(e)) { + return 'toggle-mode'; + } + + return getDefaultKeyBinding(e); + } + + /** + * "Does the right thing" to create an EditorState, based on: + * - whether we've got rich text mode enabled + * - contentState was passed in + */ + createEditorState(contentState: ?ContentState): EditorState { + let func = contentState ? EditorState.createWithContent : EditorState.createEmpty; + let args = contentState ? [contentState] : []; + if(this.state.isRichtextEnabled) { + args.push(getScopedDecorator(this.props)); + } + return func.apply(null, args); } componentWillMount() { - this.oldScrollHeight = 0; - this.markdownEnabled = MARKDOWN_ENABLED; const component = this; this.sentHistory = { // The list of typed messages. Index 0 is more recent @@ -132,7 +172,6 @@ export default class MessageComposerInput extends React.Component { this.element.value = this.originalText; } - component.resizeInput(); return true; }, @@ -140,18 +179,17 @@ export default class MessageComposerInput extends React.Component { // save the currently entered text in order to restore it later. // NB: This isn't 'originalText' because we want to restore // sent history items too! - const contentHTML = contentStateToHTML(component.state.editorState.getCurrentContent()); - console.error(contentHTML); - window.sessionStorage.setItem("input_" + this.roomId, contentHTML); + let contentJSON = JSON.stringify(convertToRaw(component.state.editorState.getCurrentContent())); + window.sessionStorage.setItem("input_" + this.roomId, contentJSON); }, setLastTextEntry: function () { - const contentHTML = window.sessionStorage.getItem("input_" + this.roomId); - console.error(contentHTML); - if (contentHTML) { - const content = HTMLtoContentState(contentHTML); - component.setState({editorState: EditorState.createWithContent(content, getScopedDecorator(component.props))}); - component.resizeInput(); + let contentJSON = window.sessionStorage.getItem("input_" + this.roomId); + if (contentJSON) { + let content = convertFromRaw(JSON.parse(contentJSON)); + component.setState({ + editorState: component.createEditorState(content) + }); } } }; @@ -163,10 +201,10 @@ export default class MessageComposerInput extends React.Component { this.refs.editor, this.props.room.roomId ); - this.resizeInput(); - if (this.props.tabComplete) { - this.props.tabComplete.setTextArea(this.refs.editor); - } + // this is disabled for now, since https://github.com/matrix-org/matrix-react-sdk/pull/296 will land soon + // if (this.props.tabComplete) { + // this.props.tabComplete.setEditor(this.refs.editor); + // } } componentWillUnmount() { @@ -176,44 +214,33 @@ export default class MessageComposerInput extends React.Component { onAction(payload) { var editor = this.refs.editor; + switch (payload.action) { case 'focus_composer': editor.focus(); break; + + // TODO change this so we insert a complete user alias + case 'insert_displayname': - console.error('fixme'); - if (textarea.value.length) { - var left = textarea.value.substring(0, textarea.selectionStart); - var right = textarea.value.substring(textarea.selectionEnd); - if (right.length) { - left += payload.displayname; - } - else { - left = left.replace(/( ?)$/, " " + payload.displayname); - } - textarea.value = left + right; - textarea.focus(); - textarea.setSelectionRange(left.length, left.length); - } - else { - textarea.value = payload.displayname + ": "; - textarea.focus(); + 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') + }); + editor.focus(); } break; } } onKeyDown(ev) { - if (ev.keyCode === KeyCode.ENTER && !ev.shiftKey) { - var input = this.refs.textarea.value; - if (input.length === 0) { - ev.preventDefault(); - return; - } - this.sentHistory.push(input); - this.onEnter(ev); - } - else if (ev.keyCode === KeyCode.UP || ev.keyCode === KeyCode.DOWN) { + 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; @@ -222,48 +249,9 @@ export default class MessageComposerInput extends React.Component { setTimeout(() => { if (this.refs.textarea.selectionStart == oldSelectionStart) { this.sentHistory.next(keyCode === KeyCode.UP ? 1 : -1); - this.resizeInput(); } }, 0); } - - if (this.props.tabComplete) { - this.props.tabComplete.onKeyDown(ev); - } - - var self = this; - setTimeout(function() { - if (self.refs.textarea && self.refs.textarea.value != '') { - self.onTypingActivity(); - } else { - self.onFinishedTyping(); - } - }, 10); // XXX: what is this 10ms setTimeout doing? Looks hacky :( - } - - resizeInput() { - console.error('fixme'); - // scrollHeight is at least equal to clientHeight, so we have to - // temporarily crimp clientHeight to 0 to get an accurate scrollHeight value - // this.refs.textarea.style.height = "20px"; // 20 hardcoded from CSS - // var newHeight = Math.min(this.refs.textarea.scrollHeight, - // this.constructor.MAX_HEIGHT); - // this.refs.textarea.style.height = Math.ceil(newHeight) + "px"; - // this.oldScrollHeight = this.refs.textarea.scrollHeight; - // - // if (this.props.onResize) { - // // kick gemini-scrollbar to re-layout - // this.props.onResize(); - // } - } - - onKeyUp(ev) { - if (this.refs.textarea.scrollHeight !== this.oldScrollHeight || - ev.keyCode === KeyCode.DELETE || - ev.keyCode === KeyCode.BACKSPACE) - { - this.resizeInput(); - } } onEnter(ev) { @@ -271,24 +259,24 @@ export default class MessageComposerInput extends React.Component { // bodge for now to set markdown state on/off. We probably want a separate // area for "local" commands which don't hit out to the server. - if (contentText.indexOf("/markdown") === 0) { - ev.preventDefault(); - this.refs.textarea.value = ''; - if (contentText.indexOf("/markdown on") === 0) { - this.markdownEnabled = true; - } - else if (contentText.indexOf("/markdown off") === 0) { - this.markdownEnabled = false; - } - else { - var ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); - Modal.createDialog(ErrorDialog, { - title: "Unknown command", - description: "Usage: /markdown on|off" - }); - } - return; - } + // if (contentText.indexOf("/markdown") === 0) { + // ev.preventDefault(); + // this.refs.textarea.value = ''; + // if (contentText.indexOf("/markdown on") === 0) { + // this.markdownEnabled = true; + // } + // else if (contentText.indexOf("/markdown off") === 0) { + // this.markdownEnabled = false; + // } + // else { + // const ErrorDialog = sdk.getComponent("dialogs.ErrorDialog"); + // Modal.createDialog(ErrorDialog, { + // title: "Unknown command", + // description: "Usage: /markdown on|off" + // }); + // } + // return; + // } var cmd = SlashCommands.processInput(this.props.room.roomId, contentText); if (cmd) { @@ -326,32 +314,23 @@ export default class MessageComposerInput extends React.Component { contentText = contentText.substring(4); } else if (contentText[0] === '/') { - contentText = contentText.substring(1); + contentText = contentText.substring(1); } var htmlText; if (this.markdownEnabled && (htmlText = mdownToHtml(contentText)) !== contentText) { - sendMessagePromise = isEmote ? + sendMessagePromise = isEmote ? MatrixClientPeg.get().sendHtmlEmote(this.props.room.roomId, contentText, htmlText) : MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, htmlText); } else { - sendMessagePromise = isEmote ? + sendMessagePromise = isEmote ? MatrixClientPeg.get().sendEmoteMessage(this.props.room.roomId, contentText) : MatrixClientPeg.get().sendTextMessage(this.props.room.roomId, contentText); } - sendMessagePromise.done(function() { - dis.dispatch({ - action: 'message_sent' - }); - }, function() { - dis.dispatch({ - action: 'message_send_failed' - }); - }); + this.refs.textarea.value = ''; - this.resizeInput(); ev.preventDefault(); } @@ -436,7 +415,28 @@ export default class MessageComposerInput extends React.Component { } handleKeyCommand(command) { - const newState = RichUtils.handleKeyCommand(this.state.editorState, command); + if(command === 'toggle-mode') { + this.setState({ + isRichtextEnabled: !this.state.isRichtextEnabled + }); + + if(!this.state.isRichtextEnabled) { + let html = mdownToHtml(this.state.editorState.getCurrentContent().getPlainText()); + this.setState({ + editorState: this.createEditorState(HTMLtoContentState(html)) + }); + } else { + let markdown = stateToMarkdown(this.state.editorState.getCurrentContent()); + let contentState = ContentState.createFromText(markdown); + this.setState({ + editorState: this.createEditorState(contentState) + }); + } + + return true; + } + + let newState = RichUtils.handleKeyCommand(this.state.editorState, command); if (newState) { this.onChange(newState); return true; @@ -452,31 +452,50 @@ export default class MessageComposerInput extends React.Component { if(!contentState.hasText()) return true; - const contentText = contentState.getPlainText(), - contentHTML = contentStateToHTML(contentState); + let contentText = contentState.getPlainText(), contentHTML; - MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, contentHTML); + if(this.state.isRichtextEnabled) { + contentHTML = contentStateToHTML(contentState); + } else { + contentHTML = mdownToHtml(contentText); + } + + this.sentHistory.push(contentHTML); + let sendMessagePromise = MatrixClientPeg.get().sendHtmlMessage(this.props.room.roomId, contentText, contentHTML); + + sendMessagePromise.done(() => { + dis.dispatch({ + action: 'message_sent' + }); + }, () => { + dis.dispatch({ + action: 'message_send_failed' + }); + }); this.setState({ - editorState: EditorState.createEmpty(getScopedDecorator(this.props)) + editorState: this.createEditorState() }); return true; } render() { - const containerStyle = { - overflow: 'auto' - }; + let className = "mx_MessageComposer_input"; + + if(this.state.isRichtextEnabled) { + className += " mx_MessageComposer_input_rte"; // placeholder indicator for RTE mode + } + return ( -
+
this.onChange(state)} + keyBindingFn={MessageComposerInput.getKeyBinding} handleKeyCommand={(command) => this.handleKeyCommand(command)} handleReturn={ev => this.handleReturn(ev)} />
@@ -494,6 +513,3 @@ MessageComposerInput.propTypes = { // js-sdk Room object room: React.PropTypes.object.isRequired }; - -// the height we limit the composer to -MessageComposerInput.MAX_HEIGHT = 100;