diff --git a/docs/slate-formats.md b/docs/slate-formats.md new file mode 100644 index 00000000000..7bb2fc9c5ff --- /dev/null +++ b/docs/slate-formats.md @@ -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
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. \ No newline at end of file diff --git a/package.json b/package.json index d639a4e51ce..661febf6af5 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,7 @@ "react-beautiful-dnd": "^4.0.1", "react-dom": "^15.6.0", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", - "slate": "^0.33.4", + "slate": "0.33.4", "slate-react": "^0.12.4", "slate-html-serializer": "^0.6.1", "slate-md-serializer": "matrix-org/slate-md-serializer#f7c4ad3", diff --git a/src/ComposerHistoryManager.js b/src/ComposerHistoryManager.js index e78fbcdc3b8..0164e6c4cde 100644 --- a/src/ComposerHistoryManager.js +++ b/src/ComposerHistoryManager.js @@ -51,8 +51,8 @@ class HistoryItem { export default class ComposerHistoryManager { history: Array
- if (i !== contentDiv.children.length - 1) {
- contentHTML += '
';
- }
- } else if (element.tagName.toLowerCase() === 'pre') {
- // Replace "
\n" with "\n" within `
` tags because the
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 += '' + - element.innerHTML.replace(/'; - } else { - const temp = document.createElement('div'); - temp.appendChild(element.cloneNode(true)); - contentHTML += temp.innerHTML; - } - } - - return contentHTML; -} -*/ - /* * Given an untrusted HTML string, return a React node with an sanitized version * of that HTML. diff --git a/src/Markdown.js b/src/Markdown.js index 9d9a8621c9f..dc0d5962fdd 100644 --- a/src/Markdown.js +++ b/src/Markdown.js @@ -180,14 +180,6 @@ export default class Markdown { if (is_multi_line(node) && node.next) this.lit('\n\n'); }; - // convert MD links into console-friendly ' < http://foo >' style links - // ...except given this function never gets called with links, it's useless. - // renderer.link = function(node, entering) { - // if (!entering) { - // this.lit(` < ${node.destination} >`); - // } - // }; - return renderer.render(this.parsed); } } diff --git a/src/autocomplete/Autocompleter.js b/src/autocomplete/Autocompleter.js index b3c20a713c4..7f91676cc3a 100644 --- a/src/autocomplete/Autocompleter.js +++ b/src/autocomplete/Autocompleter.js @@ -29,9 +29,9 @@ import NotifProvider from './NotifProvider'; import Promise from 'bluebird'; export type SelectionRange = { - beginning: boolean, - start: number, - end: number + beginning: boolean, // whether the selection is in the first block of the editor or not + start: number, // byte offset relative to the start anchor of the current editor selection. + end: number, // byte offset relative to the end anchor of the current editor selection. }; export type Completion = { diff --git a/src/autocomplete/CommandProvider.js b/src/autocomplete/CommandProvider.js index 0377ff33952..850d97ab712 100644 --- a/src/autocomplete/CommandProvider.js +++ b/src/autocomplete/CommandProvider.js @@ -43,7 +43,7 @@ export default class CommandProvider extends AutocompleteProvider { let matches = []; // check if the full match differs from the first word (i.e. returns false if the command has args) - if (command[0] !== command[1]) { + if (command[0] !== command[1]) { // The input looks like a command with arguments, perform exact match const name = command[1].substr(1); // strip leading `/` if (CommandMap[name]) { diff --git a/src/autocomplete/UserProvider.js b/src/autocomplete/UserProvider.js index 156aac2eb8c..7998337e2e5 100644 --- a/src/autocomplete/UserProvider.js +++ b/src/autocomplete/UserProvider.js @@ -111,7 +111,7 @@ export default class UserProvider extends AutocompleteProvider { // relies on the length of the entity === length of the text in the decoration. completion: user.rawDisplayName.replace(' (IRC)', ''), completionId: user.userId, - suffix: (selection.beginning && range.start === 0) ? ': ' : ' ', + suffix: (selection.beginning && selection.start === 0) ? ': ' : ' ', href: makeUserPermalink(user.userId), component: (
\n/g, '\n').trim() + - '- { props.hasBackground && } + { props.hasBackground && } ; } diff --git a/src/components/views/rooms/EventTile.js b/src/components/views/rooms/EventTile.js index 9ed73c39b16..fff04d476d9 100644 --- a/src/components/views/rooms/EventTile.js +++ b/src/components/views/rooms/EventTile.js @@ -56,7 +56,7 @@ const stateEventTileTypes = { 'm.room.topic': 'messages.TextualEvent', 'm.room.power_levels': 'messages.TextualEvent', 'm.room.pinned_events': 'messages.TextualEvent', - 'm.room.server_acl' : 'messages.TextualEvent', + 'm.room.server_acl': 'messages.TextualEvent', 'im.vector.modular.widgets': 'messages.TextualEvent', }; diff --git a/src/components/views/rooms/MessageComposer.js b/src/components/views/rooms/MessageComposer.js index 746f3909f2f..657fd463fd5 100644 --- a/src/components/views/rooms/MessageComposer.js +++ b/src/components/views/rooms/MessageComposer.js @@ -16,7 +16,7 @@ limitations under the License. */ import React from 'react'; import PropTypes from 'prop-types'; -import { _t } from '../../../languageHandler'; +import { _t, _td } from '../../../languageHandler'; import CallHandler from '../../../CallHandler'; import MatrixClientPeg from '../../../MatrixClientPeg'; import Modal from '../../../Modal'; @@ -26,6 +26,17 @@ import RoomViewStore from '../../../stores/RoomViewStore'; import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; import Stickerpicker from './Stickerpicker'; +const formatButtonList = [ + _td("bold"), + _td("italic"), + _td("deleted"), + _td("underlined"), + _td("inline-code"), + _td("block-quote"), + _td("bulleted-list"), + _td("numbered-list"), +]; + export default class MessageComposer extends React.Component { constructor(props, context) { super(props, context); @@ -322,18 +333,17 @@ export default class MessageComposer extends React.Component { let formatBar; if (this.state.showFormatting && this.state.inputState.isRichTextEnabled) { const {marks, blockType} = this.state.inputState; - const formatButtons = ["bold", "italic", "deleted", "underlined", "inline-code", "block-quote", "bulleted-list", "numbered-list"].map( - (name) => { - const active = marks.some(mark => mark.type === name) || blockType === name; - const suffix = active ? '-on' : ''; - const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name); - const className = 'mx_MessageComposer_format_button mx_filterFlipColor'; - return ; + const formatButtons = formatButtonList.map((name) => { + const active = marks.some(mark => mark.type === name) || blockType === name; + const suffix = active ? '-on' : ''; + const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name); + const className = 'mx_MessageComposer_format_button mx_filterFlipColor'; + return ; }, ); diff --git a/src/components/views/rooms/MessageComposerInput.js b/src/components/views/rooms/MessageComposerInput.js index eed6b18aa4a..89e4107a56c 100644 --- a/src/components/views/rooms/MessageComposerInput.js +++ b/src/components/views/rooms/MessageComposerInput.js @@ -21,17 +21,14 @@ import type SyntheticKeyboardEvent from 'react/lib/SyntheticKeyboardEvent'; import { Editor } from 'slate-react'; import { getEventTransfer } from 'slate-react'; -import { Value, Document, Event, Block, Inline, Text, Range, Node } from 'slate'; +import { Value, Document, Block, Inline, Text, Range, Node } from 'slate'; +import type { Change } from 'slate'; import Html from 'slate-html-serializer'; import Md from 'slate-md-serializer'; import Plain from 'slate-plain-serializer'; import PlainWithPillsSerializer from "../../../autocomplete/PlainWithPillsSerializer"; -// import {Editor, EditorState, RichUtils, CompositeDecorator, Modifier, -// getDefaultKeyBinding, KeyBindingUtil, ContentState, ContentBlock, SelectionState, -// Entity} from 'draft-js'; - import classNames from 'classnames'; import Promise from 'bluebird'; @@ -54,7 +51,7 @@ import Markdown from '../../../Markdown'; import ComposerHistoryManager from '../../../ComposerHistoryManager'; import MessageComposerStore from '../../../stores/MessageComposerStore'; -import {MATRIXTO_MD_LINK_PATTERN} from '../../../linkify-matrix'; +import {MATRIXTO_MD_LINK_PATTERN, MATRIXTO_URL_PATTERN} from '../../../linkify-matrix'; const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g'); import {asciiRegexp, unicodeRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort, toShort} from 'emojione'; @@ -118,6 +115,15 @@ function onSendMessageFailed(err, room) { }); } +function rangeEquals(a: Range, b: Range): boolean { + return (a.anchorKey === b.anchorKey + && a.anchorOffset === b.anchorOffset + && a.focusKey === b.focusKey + && a.focusOffset === b.focusOffset + && a.isFocused === b.isFocused + && a.isBackward === b.isBackward); +} + /* * The textInput part of the MessageComposer */ @@ -146,28 +152,17 @@ export default class MessageComposerInput extends React.Component { Analytics.setRichtextMode(isRichTextEnabled); - this.state = { - // whether we're in rich text or markdown mode - isRichTextEnabled, - - // the currently displayed editor state (note: this is always what is modified on input) - editorState: this.createEditorState( - isRichTextEnabled, - MessageComposerStore.getEditorState(this.props.room.roomId), - ), - - // the original editor state, before we started tabbing through completions - originalEditorState: null, - - // the virtual state "above" the history stack, the message currently being composed that - // we want to persist whilst browsing history - currentlyComposedEditorState: null, + this.client = MatrixClientPeg.get(); - // whether there were any completions - someCompletions: null, - }; + // 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; - this.client = MatrixClientPeg.get(); + // track whether we've just pressed an arrowkey left or right in order to skip void nodes. + // see https://github.com/ianstormtaylor/slate/issues/762#issuecomment-304855095 + this.direction = ''; this.plainWithMdPills = new PlainWithPillsSerializer({ pillFormat: 'md' }); this.plainWithIdPills = new PlainWithPillsSerializer({ pillFormat: 'id' }); @@ -176,18 +171,35 @@ export default class MessageComposerInput extends React.Component { this.md = new Md({ rules: [ { + // if serialize returns undefined it falls through to the default hardcoded + // serialization rules serialize: (obj, children) => { - if (obj.object === 'inline') { - switch (obj.type) { - case 'pill': - return `[${ obj.data.get('completion') }](${ obj.data.get('href') })`; - case 'emoji': - return obj.data.get('emojiUnicode'); - } + if (obj.object !== 'inline') return; + switch (obj.type) { + case 'pill': + return `[${ obj.data.get('completion') }](${ obj.data.get('href') })`; + case 'emoji': + return obj.data.get('emojiUnicode'); } - } - } - ] + }, + }, { + serialize: (obj, children) => { + if (obj.object !== 'mark') return; + // XXX: slate-md-serializer consumes marks other than bold, italic, code, inserted, deleted + switch (obj.type) { + case 'underlined': + return `${ children }`; + case 'deleted': + return ` ${ children }`; + case 'code': + // XXX: we only ever get given `code` regardless of whether it was inline or block + // XXX: workaround for https://github.com/tommoor/slate-md-serializer/issues/14 + // strip single backslashes from children, as they would have been escaped here + return `\`${ children.split('\\').map((v) => v ? v : '\\').join('') }\``; + } + }, + }, + ], }); this.html = new Html({ @@ -278,20 +290,46 @@ export default class MessageComposerInput extends React.Component { ] }); - this.suppressAutoComplete = false; - this.direction = ''; + const savedState = MessageComposerStore.getEditorState(this.props.room.roomId); + this.state = { + // whether we're in rich text or markdown mode + isRichTextEnabled, + + // the currently displayed editor state (note: this is always what is modified on input) + editorState: this.createEditorState( + isRichTextEnabled, + savedState ? savedState.editor_state : undefined, + savedState ? savedState.rich_text : undefined, + ), + + // the original editor state, before we started tabbing through completions + originalEditorState: null, + + // the virtual state "above" the history stack, the message currently being composed that + // we want to persist whilst browsing history + currentlyComposedEditorState: null, + + // whether there were any completions + someCompletions: null, + }; } /* * "Does the right thing" to create an Editor value, based on: * - whether we've got rich text mode enabled * - contentState was passed in + * - whether the contentState that was passed in was rich text */ - createEditorState(richText: boolean, editorState: ?Value): Value { + createEditorState(wantRichText: boolean, editorState: ?Value, wasRichText: ?boolean): Value { if (editorState instanceof Value) { + if (wantRichText && !wasRichText) { + return this.mdToRichEditorState(editorState); + } + if (wasRichText && !wantRichText) { + return this.richToMdEditorState(editorState); + } return editorState; - } - else { + } else { // ...or create a new one. return Plain.deserialize('', { defaultBlock: DEFAULT_NODE }); } @@ -299,7 +337,7 @@ export default class MessageComposerInput extends React.Component { componentDidMount() { this.dispatcherRef = dis.register(this.onAction); - this.historyManager = new ComposerHistoryManager(this.props.room.roomId); + this.historyManager = new ComposerHistoryManager(this.props.room.roomId, 'mx_slate_composer_history_'); } componentWillUnmount() { @@ -342,7 +380,7 @@ export default class MessageComposerInput extends React.Component { // If so, what should be the format, and how do we differentiate it from replies? const quote = Block.create('block-quote'); - if (this.state.isRichTextEnabled) { + if (this.state.isRichTextEnabled) { let change = editorState.change(); if (editorState.anchorText.text === '' && editorState.anchorBlock.nodes.size === 1) { // replace the current block rather than split the block @@ -360,7 +398,6 @@ export default class MessageComposerInput extends React.Component { let fragmentChange = fragment.change(); fragmentChange.moveToRangeOf(fragment.document) .wrapBlock(quote); - //.setBlocks('block-quote'); // FIXME: handle pills and use commonmark rather than md-serialize const md = this.md.serialize(fragmentChange.value); @@ -441,39 +478,37 @@ export default class MessageComposerInput extends React.Component { } } - onChange = (change: Change, originalEditorState: value) => { - + onChange = (change: Change, originalEditorState?: Value) => { let editorState = change.value; if (this.direction !== '') { const focusedNode = editorState.focusInline || editorState.focusText; if (focusedNode.isVoid) { + // XXX: does this work in RTL? + const edge = this.direction === 'Previous' ? 'End' : 'Start'; if (editorState.isCollapsed) { - change = change[`collapseToEndOf${ this.direction }Text`](); - } - else { + change = change[`collapseTo${ edge }Of${ this.direction }Text`](); + } else { const block = this.direction === 'Previous' ? editorState.previousText : editorState.nextText; if (block) { - change = change.moveFocusToEndOf(block) + change = change[`moveFocusTo${ edge }Of`](block); } } editorState = change.value; } } + // when selection changes hide the autocomplete + if (!rangeEquals(this.state.editorState.selection, editorState.selection)) { + this.autocomplete.hide(); + } + if (!editorState.document.isEmpty) { this.onTypingActivity(); } else { this.onFinishedTyping(); } - /* - // XXX: what was this ever doing? - if (!state.hasOwnProperty('originalEditorState')) { - state.originalEditorState = null; - } - */ - if (editorState.startText !== null) { const text = editorState.startText.text; const currentStartOffset = editorState.startOffset; @@ -501,9 +536,7 @@ export default class MessageComposerInput extends React.Component { } // emojioneify any emoji - - // XXX: is getTextsAsArray a private API? - editorState.document.getTextsAsArray().forEach(node => { + editorState.document.getTexts().forEach(node => { if (node.text !== '' && HtmlUtils.containsEmoji(node.text)) { let match; while ((match = EMOJI_REGEX.exec(node.text)) !== null) { @@ -535,36 +568,6 @@ export default class MessageComposerInput extends React.Component { editorState = change.value; } -/* - const currentBlock = editorState.getSelection().getStartKey(); - const currentSelection = editorState.getSelection(); - const currentStartOffset = editorState.getSelection().getStartOffset(); - - const block = editorState.getCurrentContent().getBlockForKey(currentBlock); - const text = block.getText(); - - const entityBeforeCurrentOffset = block.getEntityAt(currentStartOffset - 1); - const entityAtCurrentOffset = block.getEntityAt(currentStartOffset); - - // If the cursor is on the boundary between an entity and a non-entity and the - // text before the cursor has whitespace at the end, set the entity state of the - // character before the cursor (the whitespace) to null. This allows the user to - // stop editing the link. - if (entityBeforeCurrentOffset && !entityAtCurrentOffset && - /\s$/.test(text.slice(0, currentStartOffset))) { - editorState = RichUtils.toggleLink( - editorState, - currentSelection.merge({ - anchorOffset: currentStartOffset - 1, - focusOffset: currentStartOffset, - }), - null, - ); - // Reset selection - editorState = EditorState.forceSelection(editorState, currentSelection); - } -*/ - if (this.props.onInputStateChanged && editorState.blocks.size > 0) { let blockType = editorState.blocks.first().type; // console.log("onInputStateChanged; current block type is " + blockType + " and marks are " + editorState.activeMarks); @@ -591,10 +594,10 @@ export default class MessageComposerInput extends React.Component { dis.dispatch({ action: 'editor_state', room_id: this.props.room.roomId, + rich_text: this.state.isRichTextEnabled, editor_state: editorState, }); - /* Since a modification was made, set originalEditorState to null, since newState is now our original */ this.setState({ editorState, originalEditorState: originalEditorState || null @@ -672,7 +675,7 @@ export default class MessageComposerInput extends React.Component { hasMark = type => { const { editorState } = this.state - return editorState.activeMarks.some(mark => mark.type == type) + return editorState.activeMarks.some(mark => mark.type === type) }; /** @@ -684,10 +687,10 @@ export default class MessageComposerInput extends React.Component { hasBlock = type => { const { editorState } = this.state - return editorState.blocks.some(node => node.type == type) + return editorState.blocks.some(node => node.type === type) }; - onKeyDown = (ev: Event, change: Change, editor: Editor) => { + onKeyDown = (ev: KeyboardEvent, change: Change, editor: Editor) => { this.suppressAutoComplete = false; @@ -702,6 +705,23 @@ export default class MessageComposerInput extends React.Component { this.direction = ''; } + switch (ev.keyCode) { + case KeyCode.ENTER: + return this.handleReturn(ev, change); + case KeyCode.BACKSPACE: + return this.onBackspace(ev, change); + case KeyCode.UP: + return this.onVerticalArrow(ev, true); + case KeyCode.DOWN: + return this.onVerticalArrow(ev, false); + case KeyCode.TAB: + return this.onTab(ev); + case KeyCode.ESCAPE: + return this.onEscape(ev); + case KeyCode.SPACE: + return this.onSpace(ev, change); + } + if (isOnlyCtrlOrCmdKeyEvent(ev)) { const ctrlCmdCommand = { // C-m => Toggles between rich text and markdown modes @@ -715,37 +735,36 @@ export default class MessageComposerInput extends React.Component { if (ctrlCmdCommand) { return this.handleKeyCommand(ctrlCmdCommand); } - return; } + }; - switch (ev.keyCode) { - case KeyCode.ENTER: - return this.handleReturn(ev, change); - case KeyCode.BACKSPACE: - return this.onBackspace(ev, change); - case KeyCode.UP: - return this.onVerticalArrow(ev, true); - case KeyCode.DOWN: - return this.onVerticalArrow(ev, false); - case KeyCode.TAB: - return this.onTab(ev); - case KeyCode.ESCAPE: - return this.onEscape(ev); - default: - // don't intercept it - return; + onSpace = (ev: KeyboardEvent, change: Change): Change => { + if (ev.metaKey || ev.altKey || ev.shiftKey || ev.ctrlKey) { + return; } + + // drop a point in history so the user can undo a word + // XXX: this seems nasty but adding to history manually seems a no-go + ev.preventDefault(); + return change.setOperationFlag("skip", false).setOperationFlag("merge", false).insertText(ev.key); }; - onBackspace = (ev: Event, change: Change): Change => { - if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.ctrlKey || ev.shiftKey) { + onBackspace = (ev: KeyboardEvent, change: Change): Change => { + if (ev.metaKey || ev.altKey || ev.shiftKey) { return; } + const { editorState } = this.state; + + // Allow Ctrl/Cmd-Backspace when focus starts at the start of the composer (e.g select-all) + // for some reason if slate sees you Ctrl-backspace and your anchorOffset=0 it just resets your focus + if (!editorState.isCollapsed && editorState.anchorOffset === 0) { + return change.delete(); + } + if (this.state.isRichTextEnabled) { // let backspace exit lists const isList = this.hasBlock('list-item'); - const { editorState } = this.state; if (isList && editorState.anchorOffset == 0) { change @@ -805,7 +824,7 @@ export default class MessageComposerInput extends React.Component { // Handle the extra wrapping required for list buttons. const isList = this.hasBlock('list-item'); const isType = editorState.blocks.some(block => { - return !!document.getClosest(block.key, parent => parent.type == type); + return !!document.getClosest(block.key, parent => parent.type === type); }); if (isList && isType) { @@ -816,7 +835,7 @@ export default class MessageComposerInput extends React.Component { } else if (isList) { change .unwrapBlock( - type == 'bulleted-list' ? 'numbered-list' : 'bulleted-list' + type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list' ) .wrapBlock(type); } else { @@ -986,7 +1005,7 @@ export default class MessageComposerInput extends React.Component { let contentHTML; // only look for commands if the first block contains simple unformatted text - // i.e. no pills or rich-text formatting. + // i.e. no pills or rich-text formatting and begins with a /. let cmd, commandText; const firstChild = editorState.document.nodes.get(0); const firstGrandChild = firstChild && firstChild.nodes.get(0); @@ -995,7 +1014,7 @@ export default class MessageComposerInput extends React.Component { firstGrandChild.text[0] === '/') { commandText = this.plainWithIdPills.serialize(editorState); - cmd = SlashCommands.processInput(this.props.room.roomId, commandText); + cmd = processCommandInput(this.props.room.roomId, commandText); } if (cmd) { @@ -1067,8 +1086,8 @@ export default class MessageComposerInput extends React.Component { // didn't contain any formatting in the first place... contentText = mdWithPills.toPlaintext(); } else { - // to avoid ugliness clients which can't parse HTML we don't send pills - // in the plaintext body. + // to avoid ugliness on clients which ignore the HTML body we don't + // send pills in the plaintext body. contentText = this.plainWithPlainPills.serialize(editorState); contentHTML = mdWithPills.toHTML(); } @@ -1147,42 +1166,19 @@ export default class MessageComposerInput extends React.Component { // Select history only if we are not currently auto-completing if (this.autocomplete.state.completionList.length === 0) { + const selection = this.state.editorState.selection; - // determine whether our cursor is at the top or bottom of the multiline - // input box by just looking at the position of the plain old DOM selection. - const selection = window.getSelection(); - const range = selection.getRangeAt(0); - const cursorRect = range.getBoundingClientRect(); + // selection must be collapsed + if (!selection.isCollapsed) return; + const document = this.state.editorState.document; - const editorNode = ReactDOM.findDOMNode(this.refs.editor); - const editorRect = editorNode.getBoundingClientRect(); - - // heuristic to handle tall emoji, pills, etc pushing the cursor away from the top - // or bottom of the page. - // XXX: is this going to break on large inline images or top-to-bottom scripts? - const EDGE_THRESHOLD = 15; - - let navigateHistory = false; + // and we must be at the edge of the document (up=start, down=end) if (up) { - const scrollCorrection = editorNode.scrollTop; - const distanceFromTop = cursorRect.top - editorRect.top + scrollCorrection; - console.log(`Cursor distance from editor top is ${distanceFromTop}`); - if (distanceFromTop < EDGE_THRESHOLD) { - navigateHistory = true; - } - } - 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 (!selection.isAtStartOf(document)) return; + } else { + if (!selection.isAtEndOf(document)) return; } - if (!navigateHistory) return; - const selected = this.selectHistory(up); if (selected) { // We're selecting history, so prevent the key event from doing anything else @@ -1232,11 +1228,8 @@ export default class MessageComposerInput extends React.Component { // Move selection to the end of the selected history const change = editorState.change().collapseToEndOf(editorState.document); - // XXX: should we be calling this.onChange(change) now? - // Answer: yes, if we want it to do any of the fixups on stuff like emoji. - // however, this should already have been done and persisted in the history, - // so shouldn't be necessary. - + // We don't call this.onChange(change) now, as fixups on stuff like emoji + // should already have been done and persisted in the history. editorState = change.value; this.suppressAutoComplete = true; @@ -1339,6 +1332,8 @@ export default class MessageComposerInput extends React.Component { .insertText(suffix) .focus(); } + // for good hygiene, keep editorState updated to track the result of the change + // even though we don't do anything subsequently with it editorState = change.value; this.onChange(change, activeEditorState); @@ -1437,10 +1432,11 @@ export default class MessageComposerInput extends React.Component { }; onFormatButtonClicked = (name, e) => { - e.preventDefault(); // don't steal focus from the editor! + e.preventDefault(); // XXX: horrible evil hack to ensure the editor is focused so the act // of focusing it doesn't then cancel the format button being pressed + // FIXME: can we just tell handleKeyCommand's change to invoke .focus()? if (document.activeElement && document.activeElement.className !== 'mx_MessageComposer_editor') { this.refs.editor.focus(); setTimeout(()=>{ diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 486ecdf1142..2da820d7516 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -406,6 +406,14 @@ "Invited": "Invited", "Filter room members": "Filter room members", "%(userName)s (power %(powerLevelNumber)s)": "%(userName)s (power %(powerLevelNumber)s)", + "bold": "bold", + "italic": "italic", + "deleted": "deleted", + "underlined": "underlined", + "inline-code": "inline-code", + "block-quote": "block-quote", + "bulleted-list": "bulleted-list", + "numbered-list": "numbered-list", "Attachment": "Attachment", "At this time it is not possible to reply with a file so this will be sent without being a reply.": "At this time it is not possible to reply with a file so this will be sent without being a reply.", "Upload Files": "Upload Files", @@ -430,14 +438,6 @@ "Command error": "Command error", "Unable to reply": "Unable to reply", "At this time it is not possible to reply with an emote.": "At this time it is not possible to reply with an emote.", - "bold": "bold", - "italic": "italic", - "strike": "strike", - "underline": "underline", - "code": "code", - "quote": "quote", - "bullet": "bullet", - "numbullet": "numbullet", "Markdown is disabled": "Markdown is disabled", "Markdown is enabled": "Markdown is enabled", "No pinned messages.": "No pinned messages.", @@ -772,7 +772,6 @@ "Room directory": "Room directory", "Start chat": "Start chat", "And %(count)s more...|other": "And %(count)s more...", - "Share Link to User": "Share Link to User", "ex. @bob:example.com": "ex. @bob:example.com", "Add User": "Add User", "Matrix ID": "Matrix ID", diff --git a/src/stores/MessageComposerStore.js b/src/stores/MessageComposerStore.js index 0e6c856e1ba..1d37a7c9e5f 100644 --- a/src/stores/MessageComposerStore.js +++ b/src/stores/MessageComposerStore.js @@ -1,5 +1,5 @@ /* -Copyright 2017 Vector Creations Ltd +Copyright 2017, 2018 Vector Creations Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -15,6 +15,7 @@ limitations under the License. */ import dis from '../dispatcher'; import { Store } from 'flux/utils'; +import { Value } from 'slate'; const INITIAL_STATE = { // a map of room_id to rich text editor composer state @@ -54,7 +55,10 @@ class MessageComposerStore extends Store { _editorState(payload) { const editorStateMap = this._state.editorStateMap; - editorStateMap[payload.room_id] = payload.editor_state; + editorStateMap[payload.room_id] = { + editor_state: payload.editor_state, + rich_text: payload.rich_text, + }; localStorage.setItem('editor_state', JSON.stringify(editorStateMap)); this._setState({ editorStateMap: editorStateMap, @@ -62,7 +66,15 @@ class MessageComposerStore extends Store { } getEditorState(roomId) { - return this._state.editorStateMap[roomId]; + const editorStateMap = this._state.editorStateMap; + // const entry = this._state.editorStateMap[roomId]; + if (editorStateMap[roomId] && !Value.isValue(editorStateMap[roomId].editor_state)) { + // rehydrate lazily to prevent massive churn at launch and cache it + editorStateMap[roomId].editor_state = Value.fromJSON(editorStateMap[roomId].editor_state); + } + // explicitly don't setState here because the value didn't actually change, we just hydrated it, + // if a listener received an update they too would call this method and have a hydrated Value + return editorStateMap[roomId]; } reset() { diff --git a/test/components/views/rooms/MessageComposerInput-test.js b/test/components/views/rooms/MessageComposerInput-test.js index 708071df23c..662fbc7104b 100644 --- a/test/components/views/rooms/MessageComposerInput-test.js +++ b/test/components/views/rooms/MessageComposerInput-test.js @@ -10,7 +10,6 @@ const MessageComposerInput = sdk.getComponent('views.rooms.MessageComposerInput' import MatrixClientPeg from '../../../../src/MatrixClientPeg'; import RoomMember from 'matrix-js-sdk'; -/* function addTextToDraft(text) { const components = document.getElementsByClassName('public-DraftEditor-content'); if (components && components.length) { @@ -21,7 +20,9 @@ function addTextToDraft(text) { } } -describe('MessageComposerInput', () => { +// FIXME: These tests need to be updated from Draft to Slate. + +xdescribe('MessageComposerInput', () => { let parentDiv = null, sandbox = null, client = null, @@ -300,5 +301,4 @@ describe('MessageComposerInput', () => { expect(spy.args[0][1].body).toEqual('[Click here](https://some.lovely.url)'); expect(spy.args[0][1].formatted_body).toEqual('Click here'); }); -}); -*/ \ No newline at end of file +}); \ No newline at end of file