Merge pull request #1890 from matrix-org/matthew/slate

Replace Draft with Slate
This commit is contained in:
David Baker 2018-07-16 14:16:25 +01:00 committed by GitHub
commit d16ac4d80c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
42 changed files with 1397 additions and 1141 deletions

View file

@ -95,6 +95,7 @@ module.exports = {
"new-cap": ["warn"], "new-cap": ["warn"],
"key-spacing": ["warn"], "key-spacing": ["warn"],
"prefer-const": ["warn"], "prefer-const": ["warn"],
"arrow-parens": "off",
// crashes currently: https://github.com/eslint/eslint/issues/6274 // crashes currently: https://github.com/eslint/eslint/issues/6274
"generator-star-spacing": "off", "generator-star-spacing": "off",

88
docs/slate-formats.md Normal file
View file

@ -0,0 +1,88 @@
Guide to data types used by the Slate-based Rich Text Editor
------------------------------------------------------------
We always store the Slate editor state in its Value form.
The schema for the Value is the same whether the editor is in MD or rich text mode, and is currently (rather arbitrarily)
dictated by the schema expected by slate-md-serializer, simply because it was the only bit of the pipeline which
has opinions on the schema. (slate-html-serializer lets you define how to serialize whatever schema you like).
The BLOCK_TAGS and MARK_TAGS give the mapping from HTML tags to the schema's node types (for blocks, which describe
block content like divs, and marks, which describe inline formatted sections like spans).
We use <p/> as the parent tag for the message (XXX: although some tags are technically not allowed to be nested within p's)
Various conversions are performed as content is moved between HTML, MD, and plaintext representations of HTML and MD.
The primitives used are:
* Markdown.js - models commonmark-formatted MD strings (as entered by the composer in MD mode)
* toHtml() - renders them to HTML suitable for sending on the wire
* isPlainText() - checks whether the parsed MD contains anything other than simple text.
* toPlainText() - renders MD to plain text in order to remove backslashes. Only works if the MD is already plaintext (otherwise it just emits HTML)
* slate-html-serializer
* converts Values to HTML (serialising) using our schema rules
* converts HTML to Values (deserialising) using our schema rules
* slate-md-serializer
* converts rich Values to MD strings (serialising) but using a non-commonmark generic MD dialect.
* This should use commonmark, but we use the serializer here for expedience rather than writing a commonmark one.
* slate-plain-serializer
* converts Values to plain text strings (serialising them) by concatenating the strings together
* converts Values from plain text strings (deserialiasing them).
* Used to initialise the editor by deserializing "" into a Value. Apparently this is the idiomatic way to initialise a blank editor.
* Used (as a bodge) to turn a rich text editor into a MD editor, when deserialising the converted MD string of the editor into a value
* PlainWithPillsSerializer
* A fork of slate-plain-serializer which is aware of Pills (hence the name) and Emoji.
* It can be configured to output Pills as:
* "plain": Pills are rendered via their 'completion' text - e.g. 'Matthew'; used for sending messages)
* "md": Pills are rendered as MD, e.g. [Matthew](https://matrix.to/#/@matthew:matrix.org) )
* "id": Pills are rendered as IDs, e.g. '@matthew:matrix.org' (used for authoring / commands)
* Emoji nodes are converted to inline utf8 emoji.
The actual conversion transitions are:
* Quoting:
* The message being quoted is taken as HTML
* ...and deserialised into a Value
* ...and then serialised into MD via slate-md-serializer if the editor is in MD mode
* Roundtripping between MD and rich text editor mode
* From MD to richtext (mdToRichEditorState):
* Serialise the MD-format Value to a MD string (converting pills to MD) with PlainWithPillsSerializer in 'md' mode
* Convert that MD string to HTML via Markdown.js
* Deserialise that Value to HTML via slate-html-serializer
* From richtext to MD (richToMdEditorState):
* Serialise the richtext-format Value to a MD string with slate-md-serializer (XXX: this should use commonmark)
* Deserialise that to a plain text value via slate-plain-serializer
* Loading history in one format into an editor which is in the other format
* Uses the same functions as for roundtripping
* Scanning the editor for a slash command
* If the editor is a single line node starting with /, then serialize it to a string with PlainWithPillsSerializer in 'id' mode
So that pills get converted to IDs suitable for commands being passed around
* Sending messages
* In RT mode:
* If there is rich content, serialize the RT-format Value to HTML body via slate-html-serializer
* Serialize the RT-format Value to the plain text fallback via PlainWithPillsSerializer in 'plain' mode
* In MD mode:
* Serialize the MD-format Value into an MD string with PlainWithPillsSerializer in 'md' mode
* Parse the string with Markdown.js
* If it contains no formatting:
* Send as plaintext (as taken from Markdown.toPlainText())
* Otherwise
* Send as HTML (as taken from Markdown.toHtml())
* Serialize the RT-format Value to the plain text fallback via PlainWithPillsSerializer in 'plain' mode
* Pasting HTML
* Deserialize HTML to a RT Value via slate-html-serializer
* In RT mode, insert it straight into the editor as a fragment
* In MD mode, serialise it to an MD string via slate-md-serializer and then insert the string into the editor as a fragment.
The various scenarios and transitions could be drawn into a pretty diagram if one felt the urge, but hopefully the above
gives sufficient detail on how it's all meant to work.

View file

@ -59,9 +59,6 @@
"classnames": "^2.1.2", "classnames": "^2.1.2",
"commonmark": "^0.28.1", "commonmark": "^0.28.1",
"counterpart": "^0.18.0", "counterpart": "^0.18.0",
"draft-js": "^0.11.0-alpha",
"draft-js-export-html": "^0.6.0",
"draft-js-export-markdown": "^0.3.0",
"emojione": "2.2.7", "emojione": "2.2.7",
"file-saver": "^1.3.3", "file-saver": "^1.3.3",
"filesize": "3.5.6", "filesize": "3.5.6",
@ -87,6 +84,10 @@
"react-beautiful-dnd": "^4.0.1", "react-beautiful-dnd": "^4.0.1",
"react-dom": "^15.6.0", "react-dom": "^15.6.0",
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef", "react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef",
"slate": "0.33.4",
"slate-react": "^0.12.4",
"slate-html-serializer": "^0.6.1",
"slate-md-serializer": "matrix-org/slate-md-serializer#f7c4ad3",
"sanitize-html": "^1.14.1", "sanitize-html": "^1.14.1",
"text-encoding-utf-8": "^1.0.1", "text-encoding-utf-8": "^1.0.1",
"url": "^0.11.0", "url": "^0.11.0",

View file

@ -291,6 +291,10 @@ textarea {
vertical-align: middle; vertical-align: middle;
} }
.mx_emojione_selected {
background-color: $accent-color;
}
::-moz-selection { ::-moz-selection {
background-color: $accent-color; background-color: $accent-color;
color: $selection-fg-color; color: $selection-fg-color;

View file

@ -176,10 +176,7 @@ hr.mx_RoomView_myReadMarker {
z-index: 1000; z-index: 1000;
overflow: hidden; overflow: hidden;
-webkit-transition: all .2s ease-out; transition: all .2s ease-out;
-moz-transition: all .2s ease-out;
-ms-transition: all .2s ease-out;
-o-transition: all .2s ease-out;
} }
.mx_RoomView_statusArea_expanded { .mx_RoomView_statusArea_expanded {

View file

@ -27,6 +27,10 @@
padding-right: 5px; padding-right: 5px;
} }
.mx_UserPill_selected {
background-color: $accent-color ! important;
}
.mx_EventTile_highlight .mx_EventTile_content .markdown-body a.mx_UserPill_me, .mx_EventTile_highlight .mx_EventTile_content .markdown-body a.mx_UserPill_me,
.mx_EventTile_content .mx_AtRoomPill, .mx_EventTile_content .mx_AtRoomPill,
.mx_MessageComposer_input .mx_AtRoomPill { .mx_MessageComposer_input .mx_AtRoomPill {

View file

@ -448,6 +448,7 @@ limitations under the License.
.mx_EventTile_content .markdown-body h2 .mx_EventTile_content .markdown-body h2
{ {
font-size: 1.5em; font-size: 1.5em;
border-bottom: none ! important; // override GFM
} }
.mx_EventTile_content .markdown-body a { .mx_EventTile_content .markdown-body a {

View file

@ -79,12 +79,29 @@ limitations under the License.
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 60px; min-height: 60px;
justify-content: center; justify-content: start;
align-items: flex-start; align-items: flex-start;
font-size: 14px; font-size: 14px;
margin-right: 6px; margin-right: 6px;
} }
.mx_MessageComposer_editor {
width: 100%;
max-height: 120px;
min-height: 19px;
overflow: auto;
word-break: break-word;
}
// FIXME: rather unpleasant hack to get rid of <p/> margins.
// really we should be mixing in markdown-body from gfm.css instead
.mx_MessageComposer_editor > :first-child {
margin-top: 0 ! important;
}
.mx_MessageComposer_editor > :last-child {
margin-bottom: 0 ! important;
}
@keyframes visualbell @keyframes visualbell
{ {
from { background-color: #faa } from { background-color: #faa }
@ -95,28 +112,6 @@ limitations under the License.
animation: 0.2s visualbell; animation: 0.2s visualbell;
} }
.mx_MessageComposer_input_empty .public-DraftEditorPlaceholder-root {
display: none;
}
.mx_MessageComposer_input .DraftEditor-root {
width: 100%;
flex: 1;
word-break: break-word;
max-height: 120px;
min-height: 21px;
overflow: auto;
}
.mx_MessageComposer_input .DraftEditor-root .DraftEditor-editorContainer {
/* Ensure mx_UserPill and mx_RoomPill (see _RichText) are not obscured from the top */
padding-top: 2px;
}
.mx_MessageComposer .public-DraftStyleDefault-block {
overflow-x: hidden;
}
.mx_MessageComposer_input blockquote { .mx_MessageComposer_input blockquote {
color: $blockquote-fg-color; color: $blockquote-fg-color;
margin: 0 0 16px; margin: 0 0 16px;
@ -124,7 +119,7 @@ limitations under the License.
border-left: 4px solid $blockquote-bar-color; border-left: 4px solid $blockquote-bar-color;
} }
.mx_MessageComposer_input pre.public-DraftStyleDefault-pre pre { .mx_MessageComposer_input pre {
background-color: $rte-code-bg-color; background-color: $rte-code-bg-color;
border-radius: 3px; border-radius: 3px;
padding: 10px; padding: 10px;

View file

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 3 KiB

View file

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 3 KiB

View file

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

View file

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

View file

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

@ -15,46 +15,44 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {ContentState, convertToRaw, convertFromRaw} from 'draft-js'; import { Value } from 'slate';
import * as RichText from './RichText';
import Markdown from './Markdown';
import _clamp from 'lodash/clamp'; import _clamp from 'lodash/clamp';
type MessageFormat = 'html' | 'markdown'; type MessageFormat = 'rich' | 'markdown';
class HistoryItem { class HistoryItem {
// Keeping message for backwards-compatibility // We store history items in their native format to ensure history is accurate
message: string; // and then convert them if our RTE has subsequently changed format.
rawContentState: RawDraftContentState; value: Value;
format: MessageFormat = 'html'; format: MessageFormat = 'rich';
constructor(contentState: ?ContentState, format: ?MessageFormat) { constructor(value: ?Value, format: ?MessageFormat) {
this.rawContentState = contentState ? convertToRaw(contentState) : null; this.value = value;
this.format = format; this.format = format;
} }
toContentState(outputFormat: MessageFormat): ContentState { static fromJSON(obj: Object): HistoryItem {
const contentState = convertFromRaw(this.rawContentState); return new HistoryItem(
if (outputFormat === 'markdown') { Value.fromJSON(obj.value),
if (this.format === 'html') { obj.format,
return ContentState.createFromText(RichText.stateToMarkdown(contentState)); );
} }
} else {
if (this.format === 'markdown') { toJSON(): Object {
return RichText.htmlToContentState(new Markdown(contentState.getPlainText()).toHTML()); return {
} value: this.value.toJSON(),
} format: this.format,
// history item has format === outputFormat };
return contentState;
} }
} }
export default class ComposerHistoryManager { export default class ComposerHistoryManager {
history: Array<HistoryItem> = []; history: Array<HistoryItem> = [];
prefix: string; prefix: string;
lastIndex: number = 0; lastIndex: number = 0; // used for indexing the storage
currentIndex: number = 0; currentIndex: number = 0; // used for indexing the loaded validated history Array
constructor(roomId: string, prefix: string = 'mx_composer_history_') { constructor(roomId: string, prefix: string = 'mx_composer_history_') {
this.prefix = prefix + roomId; this.prefix = prefix + roomId;
@ -62,23 +60,28 @@ export default class ComposerHistoryManager {
// TODO: Performance issues? // TODO: Performance issues?
let item; let item;
for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) { for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) {
try {
this.history.push( this.history.push(
Object.assign(new HistoryItem(), JSON.parse(item)), HistoryItem.fromJSON(JSON.parse(item)),
); );
} catch (e) {
console.warn("Throwing away unserialisable history", e);
}
} }
this.lastIndex = this.currentIndex; this.lastIndex = this.currentIndex;
// reset currentIndex to account for any unserialisable history
this.currentIndex = this.history.length;
} }
save(contentState: ContentState, format: MessageFormat) { save(value: Value, format: MessageFormat) {
const item = new HistoryItem(contentState, format); const item = new HistoryItem(value, format);
this.history.push(item); this.history.push(item);
this.currentIndex = this.lastIndex + 1; this.currentIndex = this.history.length;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item)); sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON()));
} }
getItem(offset: number, format: MessageFormat): ?ContentState { getItem(offset: number): ?HistoryItem {
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.lastIndex - 1); this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1);
const item = this.history[this.currentIndex]; return this.history[this.currentIndex];
return item ? item.toContentState(format) : null;
} }
} }

View file

@ -112,41 +112,6 @@ export function charactersToImageNode(alt, useSvg, ...unicode) {
/>; />;
} }
export function processHtmlForSending(html: string): string {
const contentDiv = document.createElement('div');
contentDiv.innerHTML = html;
if (contentDiv.children.length === 0) {
return contentDiv.innerHTML;
}
let contentHTML = "";
for (let i=0; i < contentDiv.children.length; i++) {
const element = contentDiv.children[i];
if (element.tagName.toLowerCase() === 'p') {
contentHTML += element.innerHTML;
// Don't add a <br /> for the last <p>
if (i !== contentDiv.children.length - 1) {
contentHTML += '<br />';
}
} else if (element.tagName.toLowerCase() === 'pre') {
// Replace "<br>\n" with "\n" within `<pre>` tags because the <br> is
// redundant. This is a workaround for a bug in draft-js-export-html:
// https://github.com/sstur/draft-js-export-html/issues/62
contentHTML += '<pre>' +
element.innerHTML.replace(/<br>\n/g, '\n').trim() +
'</pre>';
} else {
const temp = document.createElement('div');
temp.appendChild(element.cloneNode(true));
contentHTML += temp.innerHTML;
}
}
return contentHTML;
}
/* /*
* Given an untrusted HTML string, return a React node with an sanitized version * Given an untrusted HTML string, return a React node with an sanitized version
* of that HTML. * of that HTML.
@ -418,10 +383,13 @@ class TextHighlighter extends BaseHighlighter {
* opts.highlightLink: optional href to add to highlighted words * opts.highlightLink: optional href to add to highlighted words
* opts.disableBigEmoji: optional argument to disable the big emoji class. * opts.disableBigEmoji: optional argument to disable the big emoji class.
* opts.stripReplyFallback: optional argument specifying the event is a reply and so fallback needs removing * opts.stripReplyFallback: optional argument specifying the event is a reply and so fallback needs removing
* opts.returnString: return an HTML string rather than JSX elements
* opts.emojiOne: optional param to do emojiOne (default true)
*/ */
export function bodyToHtml(content, highlights, opts={}) { export function bodyToHtml(content, highlights, opts={}) {
const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body; const isHtmlMessage = content.format === "org.matrix.custom.html" && content.formatted_body;
const doEmojiOne = opts.emojiOne === undefined ? true : opts.emojiOne;
let bodyHasEmoji = false; let bodyHasEmoji = false;
let strippedBody; let strippedBody;
@ -447,8 +415,9 @@ export function bodyToHtml(content, highlights, opts={}) {
if (opts.stripReplyFallback && formattedBody) formattedBody = ReplyThread.stripHTMLReply(formattedBody); if (opts.stripReplyFallback && formattedBody) formattedBody = ReplyThread.stripHTMLReply(formattedBody);
strippedBody = opts.stripReplyFallback ? ReplyThread.stripPlainReply(content.body) : content.body; strippedBody = opts.stripReplyFallback ? ReplyThread.stripPlainReply(content.body) : content.body;
if (doEmojiOne) {
bodyHasEmoji = containsEmoji(isHtmlMessage ? formattedBody : content.body); bodyHasEmoji = containsEmoji(isHtmlMessage ? formattedBody : content.body);
}
// Only generate safeBody if the message was sent as org.matrix.custom.html // Only generate safeBody if the message was sent as org.matrix.custom.html
if (isHtmlMessage) { if (isHtmlMessage) {
@ -473,6 +442,10 @@ export function bodyToHtml(content, highlights, opts={}) {
delete sanitizeHtmlParams.textFilter; delete sanitizeHtmlParams.textFilter;
} }
if (opts.returnString) {
return isDisplayedWithHtml ? safeBody : strippedBody;
}
let emojiBody = false; let emojiBody = false;
if (!opts.disableBigEmoji && bodyHasEmoji) { if (!opts.disableBigEmoji && bodyHasEmoji) {
EMOJI_REGEX.lastIndex = 0; EMOJI_REGEX.lastIndex = 0;

View file

@ -102,6 +102,16 @@ export default class Markdown {
// (https://github.com/vector-im/riot-web/issues/3154) // (https://github.com/vector-im/riot-web/issues/3154)
softbreak: '<br />', softbreak: '<br />',
}); });
// Trying to strip out the wrapping <p/> causes a lot more complication
// than it's worth, i think. For instance, this code will go and strip
// out any <p/> tag (no matter where it is in the tree) which doesn't
// contain \n's.
// On the flip side, <p/>s are quite opionated and restricted on where
// you can nest them.
//
// Let's try sending with <p/>s anyway for now, though.
/*
const real_paragraph = renderer.paragraph; const real_paragraph = renderer.paragraph;
renderer.paragraph = function(node, entering) { renderer.paragraph = function(node, entering) {
@ -114,16 +124,21 @@ export default class Markdown {
real_paragraph.call(this, node, entering); real_paragraph.call(this, node, entering);
} }
}; };
*/
renderer.html_inline = html_if_tag_allowed; renderer.html_inline = html_if_tag_allowed;
renderer.html_block = function(node) { renderer.html_block = function(node) {
/*
// as with `paragraph`, we only insert line breaks // as with `paragraph`, we only insert line breaks
// if there are multiple lines in the markdown. // if there are multiple lines in the markdown.
const isMultiLine = is_multi_line(node); const isMultiLine = is_multi_line(node);
if (isMultiLine) this.cr(); if (isMultiLine) this.cr();
*/
html_if_tag_allowed.call(this, node); html_if_tag_allowed.call(this, node);
/*
if (isMultiLine) this.cr(); if (isMultiLine) this.cr();
*/
}; };
return renderer.render(this.parsed); return renderer.render(this.parsed);
@ -133,7 +148,10 @@ export default class Markdown {
* Render the markdown message to plain text. That is, essentially * Render the markdown message to plain text. That is, essentially
* just remove any backslashes escaping what would otherwise be * just remove any backslashes escaping what would otherwise be
* markdown syntax * markdown syntax
* (to fix https://github.com/vector-im/riot-web/issues/2870) * (to fix https://github.com/vector-im/riot-web/issues/2870).
*
* N.B. this does **NOT** render arbitrary MD to plain text - only MD
* which has no formatting. Otherwise it emits HTML(!).
*/ */
toPlaintext() { toPlaintext() {
const renderer = new commonmark.HtmlRenderer({safe: false}); const renderer = new commonmark.HtmlRenderer({safe: false});
@ -156,6 +174,7 @@ export default class Markdown {
} }
} }
}; };
renderer.html_block = function(node) { renderer.html_block = function(node) {
this.lit(node.literal); this.lit(node.literal);
if (is_multi_line(node) && node.next) this.lit('\n\n'); if (is_multi_line(node) && node.next) this.lit('\n\n');

View file

@ -1,61 +1,30 @@
/*
Copyright 2015 - 2017 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
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.
*/
import React from 'react'; import React from 'react';
import {
Editor,
EditorState,
Modifier,
ContentState,
ContentBlock,
convertFromHTML,
DefaultDraftBlockRenderMap,
DefaultDraftInlineStyle,
CompositeDecorator,
SelectionState,
Entity,
} from 'draft-js';
import * as sdk from './index'; import * as sdk from './index';
import * as emojione from 'emojione'; import * as emojione from 'emojione';
import {stateToHTML} from 'draft-js-export-html';
import { SelectionRange } from "./autocomplete/Autocompleter"; import { SelectionRange } from "./autocomplete/Autocompleter";
import {stateToMarkdown as __stateToMarkdown} from 'draft-js-export-markdown';
const MARKDOWN_REGEX = {
LINK: /(?:\[([^\]]+)\]\(([^\)]+)\))|\<(\w+:\/\/[^\>]+)\>/g,
ITALIC: /([\*_])([\w\s]+?)\1/g,
BOLD: /([\*_])\1([\w\s]+?)\1\1/g,
HR: /(\n|^)((-|\*|_) *){3,}(\n|$)/g,
CODE: /`[^`]*`/g,
STRIKETHROUGH: /~{2}[^~]*~{2}/g,
};
const USERNAME_REGEX = /@\S+:\S+/g; export function unicodeToEmojiUri(str) {
const ROOM_REGEX = /#\S+:\S+/g;
const EMOJI_REGEX = new RegExp(emojione.unicodeRegexp, 'g');
const ZWS_CODE = 8203;
const ZWS = String.fromCharCode(ZWS_CODE); // zero width space
export function stateToMarkdown(state) {
return __stateToMarkdown(state)
.replace(
ZWS, // draft-js-export-markdown adds these
''); // this is *not* a zero width space, trust me :)
}
export const contentStateToHTML = (contentState: ContentState) => {
return stateToHTML(contentState, {
inlineStyles: {
UNDERLINE: {
element: 'u',
},
},
});
};
export function htmlToContentState(html: string): ContentState {
const blockArray = convertFromHTML(html).contentBlocks;
return ContentState.createFromBlockArray(blockArray);
}
function unicodeToEmojiUri(str) {
let replaceWith, unicode, alt; let replaceWith, unicode, alt;
if ((!emojione.unicodeAlt) || (emojione.sprites)) { if ((!emojione.unicodeAlt) || (emojione.sprites)) {
// if we are using the shortname as the alt tag then we need a reversed array to map unicode code point to shortnames // if we are using the shortname as the alt tag then we need a reversed array to map unicode code point to shortnames
@ -81,227 +50,3 @@ function unicodeToEmojiUri(str) {
return 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
const emojiDecorator = {
strategy: (contentState, contentBlock, callback) => {
findWithRegex(EMOJI_REGEX, contentBlock, callback);
},
component: (props) => {
const uri = unicodeToEmojiUri(props.children[0].props.text);
const shortname = emojione.toShort(props.children[0].props.text);
const style = {
display: 'inline-block',
width: '1em',
maxHeight: '1em',
background: `url(${uri})`,
backgroundSize: 'contain',
backgroundPosition: 'center center',
overflow: 'hidden',
};
return (<span title={shortname} style={style}><span style={{opacity: 0}}>{ props.children }</span></span>);
},
};
/**
* Returns a composite decorator which has access to provided scope.
*/
export function getScopedRTDecorators(scope: any): CompositeDecorator {
return [emojiDecorator];
}
export function getScopedMDDecorators(scope: any): CompositeDecorator {
const markdownDecorators = ['HR', 'BOLD', 'ITALIC', 'CODE', 'STRIKETHROUGH'].map(
(style) => ({
strategy: (contentState, contentBlock, callback) => {
return findWithRegex(MARKDOWN_REGEX[style], contentBlock, callback);
},
component: (props) => (
<span className={"mx_MarkdownElement mx_Markdown_" + style}>
{ props.children }
</span>
),
}));
markdownDecorators.push({
strategy: (contentState, contentBlock, callback) => {
return findWithRegex(MARKDOWN_REGEX.LINK, contentBlock, callback);
},
component: (props) => (
<a href="#" className="mx_MarkdownElement mx_Markdown_LINK">
{ props.children }
</a>
),
});
// markdownDecorators.push(emojiDecorator);
// TODO Consider renabling "syntax highlighting" when we can do it properly
return [emojiDecorator];
}
/**
* Passes rangeToReplace to modifyFn and replaces it in contentState with the result.
*/
export function modifyText(contentState: ContentState, rangeToReplace: SelectionState,
modifyFn: (text: string) => string, inlineStyle, entityKey): ContentState {
let getText = (key) => contentState.getBlockForKey(key).getText(),
startKey = rangeToReplace.getStartKey(),
startOffset = rangeToReplace.getStartOffset(),
endKey = rangeToReplace.getEndKey(),
endOffset = rangeToReplace.getEndOffset(),
text = "";
for (let currentKey = startKey;
currentKey && currentKey !== endKey;
currentKey = contentState.getKeyAfter(currentKey)) {
const blockText = getText(currentKey);
text += blockText.substring(startOffset, blockText.length);
// from now on, we'll take whole blocks
startOffset = 0;
}
// add remaining part of last block
text += getText(endKey).substring(startOffset, endOffset);
return Modifier.replaceText(contentState, rangeToReplace, modifyFn(text), inlineStyle, entityKey);
}
/**
* Computes the plaintext offsets of the given SelectionState.
* Note that this inherently means we make assumptions about what that means (no separator between ContentBlocks, etc)
* Used by autocomplete to show completions when the current selection lies within, or at the edges of a command.
*/
export function selectionStateToTextOffsets(selectionState: SelectionState,
contentBlocks: Array<ContentBlock>): {start: number, end: number} {
let offset = 0, start = 0, end = 0;
for (const block of contentBlocks) {
if (selectionState.getStartKey() === block.getKey()) {
start = offset + selectionState.getStartOffset();
}
if (selectionState.getEndKey() === block.getKey()) {
end = offset + selectionState.getEndOffset();
break;
}
offset += block.getLength();
}
return {
start,
end,
};
}
export function textOffsetsToSelectionState({start, end}: SelectionRange,
contentBlocks: Array<ContentBlock>): SelectionState {
let selectionState = SelectionState.createEmpty();
// Subtract block lengths from `start` and `end` until they are less than the current
// block length (accounting for the NL at the end of each block). Set them to -1 to
// indicate that the corresponding selection state has been determined.
for (const block of contentBlocks) {
const blockLength = block.getLength();
// -1 indicating that the position start position has been found
if (start !== -1) {
if (start < blockLength + 1) {
selectionState = selectionState.merge({
anchorKey: block.getKey(),
anchorOffset: start,
});
start = -1; // selection state for the start calculated
} else {
start -= blockLength + 1; // +1 to account for newline between blocks
}
}
// -1 indicating that the position end position has been found
if (end !== -1) {
if (end < blockLength + 1) {
selectionState = selectionState.merge({
focusKey: block.getKey(),
focusOffset: end,
});
end = -1; // selection state for the end calculated
} else {
end -= blockLength + 1; // +1 to account for newline between blocks
}
}
}
return selectionState;
}
// modified version of https://github.com/draft-js-plugins/draft-js-plugins/blob/master/draft-js-emoji-plugin/src/modifiers/attachImmutableEntitiesToEmojis.js
export function attachImmutableEntitiesToEmoji(editorState: EditorState): EditorState {
const contentState = editorState.getCurrentContent();
const blocks = contentState.getBlockMap();
let newContentState = contentState;
blocks.forEach((block) => {
const plainText = block.getText();
const addEntityToEmoji = (start, end) => {
const existingEntityKey = block.getEntityAt(start);
if (existingEntityKey) {
// avoid manipulation in case the emoji already has an entity
const entity = newContentState.getEntity(existingEntityKey);
if (entity && entity.get('type') === 'emoji') {
return;
}
}
const selection = SelectionState.createEmpty(block.getKey())
.set('anchorOffset', start)
.set('focusOffset', end);
const emojiText = plainText.substring(start, end);
newContentState = newContentState.createEntity(
'emoji', 'IMMUTABLE', { emojiUnicode: emojiText },
);
const entityKey = newContentState.getLastCreatedEntityKey();
newContentState = Modifier.replaceText(
newContentState,
selection,
emojiText,
null,
entityKey,
);
};
findWithRegex(EMOJI_REGEX, block, addEntityToEmoji);
});
if (!newContentState.equals(contentState)) {
const oldSelection = editorState.getSelection();
editorState = EditorState.push(
editorState,
newContentState,
'convert-to-immutable-emojis',
);
// this is somewhat of a hack, we're undoing selection changes caused above
// it would be better not to make those changes in the first place
editorState = EditorState.forceSelection(editorState, oldSelection);
}
return editorState;
}
export function hasMultiLineSelection(editorState: EditorState): boolean {
const selectionState = editorState.getSelection();
const anchorKey = selectionState.getAnchorKey();
const currentContent = editorState.getCurrentContent();
const currentContentBlock = currentContent.getBlockForKey(anchorKey);
const start = selectionState.getStartOffset();
const end = selectionState.getEndOffset();
const selectedText = currentContentBlock.getText().slice(start, end);
return selectedText.includes('\n');
}

View file

@ -476,6 +476,7 @@ const aliases = {
j: "join", j: "join",
}; };
/** /**
* Process the given text for /commands and perform them. * Process the given text for /commands and perform them.
* @param {string} roomId The room in which the command was performed. * @param {string} roomId The room in which the command was performed.
@ -488,7 +489,7 @@ export function processCommandInput(roomId, input) {
// trim any trailing whitespace, as it can confuse the parser for // trim any trailing whitespace, as it can confuse the parser for
// IRC-style commands // IRC-style commands
input = input.replace(/\s+$/, ''); input = input.replace(/\s+$/, '');
if (input[0] !== '/' || input[1] === '/') return null; // not a command if (input[0] !== '/') return null; // not a command
const bits = input.match(/^(\S+?)( +((.|\n)*))?$/); const bits = input.match(/^(\S+?)( +((.|\n)*))?$/);
let cmd; let cmd;

View file

@ -20,13 +20,19 @@ import React from 'react';
import type {Completion, SelectionRange} from './Autocompleter'; import type {Completion, SelectionRange} from './Autocompleter';
export default class AutocompleteProvider { export default class AutocompleteProvider {
constructor(commandRegex?: RegExp) { constructor(commandRegex?: RegExp, forcedCommandRegex?: RegExp) {
if (commandRegex) { if (commandRegex) {
if (!commandRegex.global) { if (!commandRegex.global) {
throw new Error('commandRegex must have global flag set'); throw new Error('commandRegex must have global flag set');
} }
this.commandRegex = commandRegex; this.commandRegex = commandRegex;
} }
if (forcedCommandRegex) {
if (!forcedCommandRegex.global) {
throw new Error('forcedCommandRegex must have global flag set');
}
this.forcedCommandRegex = forcedCommandRegex;
}
} }
destroy() { destroy() {
@ -40,7 +46,7 @@ export default class AutocompleteProvider {
let commandRegex = this.commandRegex; let commandRegex = this.commandRegex;
if (force && this.shouldForceComplete()) { if (force && this.shouldForceComplete()) {
commandRegex = /\S+/g; commandRegex = this.forcedCommandRegex || /\S+/g;
} }
if (commandRegex == null) { if (commandRegex == null) {

View file

@ -29,8 +29,9 @@ import NotifProvider from './NotifProvider';
import Promise from 'bluebird'; import Promise from 'bluebird';
export type SelectionRange = { export type SelectionRange = {
start: number, beginning: boolean, // whether the selection is in the first block of the editor or not
end: number 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 = { export type Completion = {
@ -80,12 +81,12 @@ export default class Autocompleter {
// Array of inspections of promises that might timeout. Instead of allowing a // Array of inspections of promises that might timeout. Instead of allowing a
// single timeout to reject the Promise.all, reflect each one and once they've all // single timeout to reject the Promise.all, reflect each one and once they've all
// settled, filter for the fulfilled ones // settled, filter for the fulfilled ones
this.providers.map((provider) => { this.providers.map(provider =>
return provider provider
.getCompletions(query, selection, force) .getCompletions(query, selection, force)
.timeout(PROVIDER_COMPLETION_TIMEOUT) .timeout(PROVIDER_COMPLETION_TIMEOUT)
.reflect(); .reflect()
}), ),
); );
return completionsList.filter( return completionsList.filter(

View file

@ -42,6 +42,7 @@ export default class CommandProvider extends AutocompleteProvider {
if (!command) return []; if (!command) return [];
let matches = []; 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 // The input looks like a command with arguments, perform exact match
const name = command[1].substr(1); // strip leading `/` const name = command[1].substr(1); // strip leading `/`

View file

@ -41,6 +41,7 @@ export default class NotifProvider extends AutocompleteProvider {
if (command && command[0] && '@room'.startsWith(command[0]) && command[0].length > 1) { if (command && command[0] && '@room'.startsWith(command[0]) && command[0].length > 1) {
return [{ return [{
completion: '@room', completion: '@room',
completionId: '@room',
suffix: ' ', suffix: ' ',
component: ( component: (
<PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={this.room} />} title="@room" description={_t("Notify the whole room")} /> <PillCompletion initialComponent={<RoomAvatar width={24} height={24} room={this.room} />} title="@room" description={_t("Notify the whole room")} />

View file

@ -0,0 +1,89 @@
/*
Copyright 2018 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
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.
*/
// Based originally on slate-plain-serializer
import { Block } from 'slate';
/**
* Plain text serializer, which converts a Slate `value` to a plain text string,
* serializing pills into various different formats as required.
*
* @type {PlainWithPillsSerializer}
*/
class PlainWithPillsSerializer {
/*
* @param {String} options.pillFormat - either 'md', 'plain', 'id'
*/
constructor(options = {}) {
const {
pillFormat = 'plain',
} = options;
this.pillFormat = pillFormat;
}
/**
* Serialize a Slate `value` to a plain text string,
* serializing pills as either MD links, plain text representations or
* ID representations as required.
*
* @param {Value} value
* @return {String}
*/
serialize = value => {
return this._serializeNode(value.document);
}
/**
* Serialize a `node` to plain text.
*
* @param {Node} node
* @return {String}
*/
_serializeNode = node => {
if (
node.object == 'document' ||
(node.object == 'block' && Block.isBlockList(node.nodes))
) {
return node.nodes.map(this._serializeNode).join('\n');
} else if (node.type == 'emoji') {
return node.data.get('emojiUnicode');
} else if (node.type == 'pill') {
switch (this.pillFormat) {
case 'plain':
return node.data.get('completion');
case 'md':
return `[${ node.data.get('completion') }](${ node.data.get('href') })`;
case 'id':
return node.data.get('completionId') || node.data.get('completion');
}
} else if (node.nodes) {
return node.nodes.map(this._serializeNode).join('');
} else {
return node.text;
}
}
}
/**
* Export.
*
* @type {PlainWithPillsSerializer}
*/
export default PlainWithPillsSerializer;

View file

@ -51,12 +51,6 @@ export default class RoomProvider extends AutocompleteProvider {
async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array<Completion> { async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array<Completion> {
const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar'); const RoomAvatar = sdk.getComponent('views.avatars.RoomAvatar');
// Disable autocompletions when composing commands because of various issues
// (see https://github.com/vector-im/riot-web/issues/4762)
if (/^(\/join|\/leave)/.test(query)) {
return [];
}
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
let completions = []; let completions = [];
const {command, range} = this.getCurrentCommand(query, selection, force); const {command, range} = this.getCurrentCommand(query, selection, force);
@ -80,6 +74,7 @@ export default class RoomProvider extends AutocompleteProvider {
const displayAlias = getDisplayAliasForRoom(room.room) || room.roomId; const displayAlias = getDisplayAliasForRoom(room.room) || room.roomId;
return { return {
completion: displayAlias, completion: displayAlias,
completionId: displayAlias,
suffix: ' ', suffix: ' ',
href: makeRoomPermalink(displayAlias), href: makeRoomPermalink(displayAlias),
component: ( component: (

View file

@ -33,14 +33,16 @@ import type {Completion, SelectionRange} from "./Autocompleter";
const USER_REGEX = /\B@\S*/g; const USER_REGEX = /\B@\S*/g;
// used when you hit 'tab' - we allow some separator chars at the beginning
// to allow you to tab-complete /mat into /(matthew)
const FORCED_USER_REGEX = /[^/,:; \t\n]\S*/g;
export default class UserProvider extends AutocompleteProvider { export default class UserProvider extends AutocompleteProvider {
users: Array<RoomMember> = null; users: Array<RoomMember> = null;
room: Room = null; room: Room = null;
constructor(room: Room) { constructor(room) {
super(USER_REGEX, { super(USER_REGEX, FORCED_USER_REGEX);
keys: ['name'],
});
this.room = room; this.room = room;
this.matcher = new FuzzyMatcher([], { this.matcher = new FuzzyMatcher([], {
keys: ['name', 'userId'], keys: ['name', 'userId'],
@ -91,12 +93,6 @@ export default class UserProvider extends AutocompleteProvider {
async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array<Completion> { async getCompletions(query: string, selection: SelectionRange, force?: boolean = false): Array<Completion> {
const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar'); const MemberAvatar = sdk.getComponent('views.avatars.MemberAvatar');
// Disable autocompletions when composing commands because of various issues
// (see https://github.com/vector-im/riot-web/issues/4762)
if (/^(\/ban|\/unban|\/op|\/deop|\/invite|\/kick|\/verify)/.test(query)) {
return [];
}
// lazy-load user list into matcher // lazy-load user list into matcher
if (this.users === null) this._makeUsers(); if (this.users === null) this._makeUsers();
@ -114,7 +110,8 @@ export default class UserProvider extends AutocompleteProvider {
// Length of completion should equal length of text in decorator. draft-js // Length of completion should equal length of text in decorator. draft-js
// relies on the length of the entity === length of the text in the decoration. // relies on the length of the entity === length of the text in the decoration.
completion: user.rawDisplayName.replace(' (IRC)', ''), completion: user.rawDisplayName.replace(' (IRC)', ''),
suffix: range.start === 0 ? ': ' : ' ', completionId: user.userId,
suffix: (selection.beginning && selection.start === 0) ? ': ' : ' ',
href: makeUserPermalink(user.userId), href: makeUserPermalink(user.userId),
component: ( component: (
<PillCompletion <PillCompletion

View file

@ -178,7 +178,7 @@ module.exports = React.createClass({
onQuoteClick: function() { onQuoteClick: function() {
dis.dispatch({ dis.dispatch({
action: 'quote', action: 'quote',
text: this.props.eventTileOps.getInnerText(), event: this.props.mxEvent,
}); });
this.closeMenu(); this.closeMenu();
}, },

View file

@ -62,6 +62,8 @@ const Pill = React.createClass({
room: PropTypes.instanceOf(Room), room: PropTypes.instanceOf(Room),
// Whether to include an avatar in the pill // Whether to include an avatar in the pill
shouldShowPillAvatar: PropTypes.bool, shouldShowPillAvatar: PropTypes.bool,
// Whether to render this pill as if it were highlit by a selection
isSelected: PropTypes.bool,
}, },
@ -268,6 +270,7 @@ const Pill = React.createClass({
const classes = classNames(pillClass, { const classes = classNames(pillClass, {
"mx_UserPill_me": userId === MatrixClientPeg.get().credentials.userId, "mx_UserPill_me": userId === MatrixClientPeg.get().credentials.userId,
"mx_UserPill_selected": this.props.isSelected,
}); });
if (this.state.pillType) { if (this.state.pillType) {

View file

@ -114,7 +114,7 @@ export default class Autocomplete extends React.Component {
processQuery(query, selection) { processQuery(query, selection) {
return this.autocompleter.getCompletions( return this.autocompleter.getCompletions(
query, selection, this.state.forceComplete, query, selection, this.state.forceComplete
).then((completions) => { ).then((completions) => {
// Only ever process the completions for the most recent query being processed // Only ever process the completions for the most recent query being processed
if (query !== this.queryRequested) { if (query !== this.queryRequested) {
@ -263,7 +263,6 @@ export default class Autocomplete extends React.Component {
const componentPosition = position; const componentPosition = position;
position++; position++;
const onMouseMove = () => this.setSelection(componentPosition);
const onClick = () => { const onClick = () => {
this.setSelection(componentPosition); this.setSelection(componentPosition);
this.onCompletionClicked(); this.onCompletionClicked();
@ -273,7 +272,6 @@ export default class Autocomplete extends React.Component {
key: i, key: i,
ref: `completion${position - 1}`, ref: `completion${position - 1}`,
className, className,
onMouseMove,
onClick, onClick,
}); });
}); });

View file

@ -16,7 +16,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { _t } from '../../../languageHandler'; import { _t, _td } from '../../../languageHandler';
import CallHandler from '../../../CallHandler'; import CallHandler from '../../../CallHandler';
import MatrixClientPeg from '../../../MatrixClientPeg'; import MatrixClientPeg from '../../../MatrixClientPeg';
import Modal from '../../../Modal'; import Modal from '../../../Modal';
@ -26,6 +26,17 @@ import RoomViewStore from '../../../stores/RoomViewStore';
import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore"; import SettingsStore, {SettingLevel} from "../../../settings/SettingsStore";
import Stickerpicker from './Stickerpicker'; import Stickerpicker from './Stickerpicker';
const formatButtonList = [
_td("bold"),
_td("italic"),
_td("deleted"),
_td("underlined"),
_td("inline-code"),
_td("block-quote"),
_td("bulleted-list"),
_td("numbered-list"),
];
export default class MessageComposer extends React.Component { export default class MessageComposer extends React.Component {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
@ -35,7 +46,6 @@ export default class MessageComposer extends React.Component {
this.onUploadFileSelected = this.onUploadFileSelected.bind(this); this.onUploadFileSelected = this.onUploadFileSelected.bind(this);
this.uploadFiles = this.uploadFiles.bind(this); this.uploadFiles = this.uploadFiles.bind(this);
this.onVoiceCallClick = this.onVoiceCallClick.bind(this); this.onVoiceCallClick = this.onVoiceCallClick.bind(this);
this.onInputContentChanged = this.onInputContentChanged.bind(this);
this._onAutocompleteConfirm = this._onAutocompleteConfirm.bind(this); this._onAutocompleteConfirm = this._onAutocompleteConfirm.bind(this);
this.onToggleFormattingClicked = this.onToggleFormattingClicked.bind(this); this.onToggleFormattingClicked = this.onToggleFormattingClicked.bind(this);
this.onToggleMarkdownClicked = this.onToggleMarkdownClicked.bind(this); this.onToggleMarkdownClicked = this.onToggleMarkdownClicked.bind(this);
@ -44,13 +54,10 @@ export default class MessageComposer extends React.Component {
this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this); this._onRoomViewStoreUpdate = this._onRoomViewStoreUpdate.bind(this);
this.state = { this.state = {
autocompleteQuery: '',
selection: null,
inputState: { inputState: {
style: [], marks: [],
blockType: null, blockType: null,
isRichtextEnabled: SettingsStore.getValue('MessageComposerInput.isRichTextEnabled'), isRichTextEnabled: SettingsStore.getValue('MessageComposerInput.isRichTextEnabled'),
wordCount: 0,
}, },
showFormatting: SettingsStore.getValue('MessageComposer.showFormatting'), showFormatting: SettingsStore.getValue('MessageComposer.showFormatting'),
isQuoting: Boolean(RoomViewStore.getQuotingEvent()), isQuoting: Boolean(RoomViewStore.getQuotingEvent()),
@ -175,13 +182,6 @@ export default class MessageComposer extends React.Component {
}); });
} }
onInputContentChanged(content: string, selection: {start: number, end: number}) {
this.setState({
autocompleteQuery: content,
selection,
});
}
onInputStateChanged(inputState) { onInputStateChanged(inputState) {
this.setState({inputState}); this.setState({inputState});
} }
@ -192,7 +192,7 @@ export default class MessageComposer extends React.Component {
} }
} }
onFormatButtonClicked(name: "bold" | "italic" | "strike" | "code" | "underline" | "quote" | "bullet" | "numbullet", event) { onFormatButtonClicked(name, event) {
event.preventDefault(); event.preventDefault();
this.messageComposerInput.onFormatButtonClicked(name, event); this.messageComposerInput.onFormatButtonClicked(name, event);
} }
@ -204,7 +204,7 @@ export default class MessageComposer extends React.Component {
onToggleMarkdownClicked(e) { onToggleMarkdownClicked(e) {
e.preventDefault(); // don't steal focus from the editor! e.preventDefault(); // don't steal focus from the editor!
this.messageComposerInput.enableRichtext(!this.state.inputState.isRichtextEnabled); this.messageComposerInput.enableRichtext(!this.state.inputState.isRichTextEnabled);
} }
render() { render() {
@ -280,14 +280,14 @@ export default class MessageComposer extends React.Component {
</div> </div>
); );
const formattingButton = ( const formattingButton = this.state.inputState.isRichTextEnabled ? (
<img className="mx_MessageComposer_formatting" <img className="mx_MessageComposer_formatting"
title={_t("Show Text Formatting Toolbar")} title={_t("Show Text Formatting Toolbar")}
src="img/button-text-formatting.svg" src="img/button-text-formatting.svg"
onClick={this.onToggleFormattingClicked} onClick={this.onToggleFormattingClicked}
style={{visibility: this.state.showFormatting ? 'hidden' : 'visible'}} style={{visibility: this.state.showFormatting ? 'hidden' : 'visible'}}
key="controls_formatting" /> key="controls_formatting" />
); ) : null;
let placeholderText; let placeholderText;
if (this.state.isQuoting) { if (this.state.isQuoting) {
@ -314,7 +314,6 @@ export default class MessageComposer extends React.Component {
room={this.props.room} room={this.props.room}
placeholder={placeholderText} placeholder={placeholderText}
onFilesPasted={this.uploadFiles} onFilesPasted={this.uploadFiles}
onContentChanged={this.onInputContentChanged}
onInputStateChanged={this.onInputStateChanged} />, onInputStateChanged={this.onInputStateChanged} />,
formattingButton, formattingButton,
stickerpickerButton, stickerpickerButton,
@ -331,11 +330,12 @@ export default class MessageComposer extends React.Component {
); );
} }
const {style, blockType} = this.state.inputState; let formatBar;
const formatButtons = ["bold", "italic", "strike", "underline", "code", "quote", "bullet", "numbullet"].map( if (this.state.showFormatting && this.state.inputState.isRichTextEnabled) {
(name) => { const {marks, blockType} = this.state.inputState;
const active = style.includes(name) || blockType === name; const formatButtons = formatButtonList.map((name) => {
const suffix = active ? '-o-n' : ''; const active = marks.some(mark => mark.type === name) || blockType === name;
const suffix = active ? '-on' : '';
const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name); const onFormatButtonClicked = this.onFormatButtonClicked.bind(this, name);
const className = 'mx_MessageComposer_format_button mx_filterFlipColor'; const className = 'mx_MessageComposer_format_button mx_filterFlipColor';
return <img className={className} return <img className={className}
@ -347,6 +347,23 @@ export default class MessageComposer extends React.Component {
}, },
); );
formatBar =
<div className="mx_MessageComposer_formatbar_wrapper">
<div className="mx_MessageComposer_formatbar">
{ formatButtons }
<div style={{flex: 1}}></div>
<img title={this.state.inputState.isRichTextEnabled ? _t("Turn Markdown on") : _t("Turn Markdown off")}
onMouseDown={this.onToggleMarkdownClicked}
className="mx_MessageComposer_formatbar_markdown mx_filterFlipColor"
src={`img/button-md-${!this.state.inputState.isRichTextEnabled}.png`} />
<img title={_t("Hide Text Formatting Toolbar")}
onClick={this.onToggleFormattingClicked}
className="mx_MessageComposer_formatbar_cancel mx_filterFlipColor"
src="img/icon-text-cancel.svg" />
</div>
</div>
}
return ( return (
<div className="mx_MessageComposer"> <div className="mx_MessageComposer">
<div className="mx_MessageComposer_wrapper"> <div className="mx_MessageComposer_wrapper">
@ -354,20 +371,7 @@ export default class MessageComposer extends React.Component {
{ controls } { controls }
</div> </div>
</div> </div>
<div className="mx_MessageComposer_formatbar_wrapper"> { formatBar }
<div className="mx_MessageComposer_formatbar" style={this.state.showFormatting ? {} : {display: 'none'}}>
{ formatButtons }
<div style={{flex: 1}}></div>
<img title={this.state.inputState.isRichtextEnabled ? _t("Turn Markdown on") : _t("Turn Markdown off")}
onMouseDown={this.onToggleMarkdownClicked}
className="mx_MessageComposer_formatbar_markdown mx_filterFlipColor"
src={`img/button-md-${!this.state.inputState.isRichtextEnabled}.png`} />
<img title={_t("Hide Text Formatting Toolbar")}
onClick={this.onToggleFormattingClicked}
className="mx_MessageComposer_formatbar_cancel mx_filterFlipColor"
src="img/icon-text-cancel.svg" />
</div>
</div>
</div> </div>
); );
} }

File diff suppressed because it is too large Load diff

View file

@ -407,6 +407,14 @@
"Invited": "Invited", "Invited": "Invited",
"Filter room members": "Filter room members", "Filter room members": "Filter room members",
"%(userName)s (power %(powerLevelNumber)s)": "%(userName)s (power %(powerLevelNumber)s)", "%(userName)s (power %(powerLevelNumber)s)": "%(userName)s (power %(powerLevelNumber)s)",
"bold": "bold",
"italic": "italic",
"deleted": "deleted",
"underlined": "underlined",
"inline-code": "inline-code",
"block-quote": "block-quote",
"bulleted-list": "bulleted-list",
"numbered-list": "numbered-list",
"Attachment": "Attachment", "Attachment": "Attachment",
"At this time it is not possible to reply with a file so this will be sent without being a reply.": "At this time it is not possible to reply with a file so this will be sent without being a reply.", "At this time it is not possible to reply with a file so this will be sent without being a reply.": "At this time it is not possible to reply with a file so this will be sent without being a reply.",
"Upload Files": "Upload Files", "Upload Files": "Upload Files",
@ -431,14 +439,6 @@
"Command error": "Command error", "Command error": "Command error",
"Unable to reply": "Unable to reply", "Unable to reply": "Unable to reply",
"At this time it is not possible to reply with an emote.": "At this time it is not possible to reply with an emote.", "At this time it is not possible to reply with an emote.": "At this time it is not possible to reply with an emote.",
"bold": "bold",
"italic": "italic",
"strike": "strike",
"underline": "underline",
"code": "code",
"quote": "quote",
"bullet": "bullet",
"numbullet": "numbullet",
"Markdown is disabled": "Markdown is disabled", "Markdown is disabled": "Markdown is disabled",
"Markdown is enabled": "Markdown is enabled", "Markdown is enabled": "Markdown is enabled",
"No pinned messages.": "No pinned messages.", "No pinned messages.": "No pinned messages.",
@ -773,7 +773,6 @@
"Room directory": "Room directory", "Room directory": "Room directory",
"Start chat": "Start chat", "Start chat": "Start chat",
"And %(count)s more...|other": "And %(count)s more...", "And %(count)s more...|other": "And %(count)s more...",
"Share Link to User": "Share Link to User",
"ex. @bob:example.com": "ex. @bob:example.com", "ex. @bob:example.com": "ex. @bob:example.com",
"Add User": "Add User", "Add User": "Add User",
"Matrix ID": "Matrix ID", "Matrix ID": "Matrix ID",

View file

@ -1,5 +1,5 @@
/* /*
Copyright 2017 Vector Creations Ltd Copyright 2017, 2018 Vector Creations Ltd
Licensed under the Apache License, Version 2.0 (the "License"); Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License. you may not use this file except in compliance with the License.
@ -15,15 +15,17 @@ limitations under the License.
*/ */
import dis from '../dispatcher'; import dis from '../dispatcher';
import { Store } from 'flux/utils'; import { Store } from 'flux/utils';
import {convertToRaw, convertFromRaw} from 'draft-js'; import { Value } from 'slate';
const INITIAL_STATE = { const INITIAL_STATE = {
editorStateMap: localStorage.getItem('content_state') ? // a map of room_id to rich text editor composer state
JSON.parse(localStorage.getItem('content_state')) : {}, editorStateMap: localStorage.getItem('editor_state') ?
JSON.parse(localStorage.getItem('editor_state')) : {},
}; };
/** /**
* A class for storing application state to do with the message composer. This is a simple * A class for storing application state to do with the message composer (specifically
* in-progress message drafts). This is a simple
* flux store that listens for actions and updates its state accordingly, informing any * flux store that listens for actions and updates its state accordingly, informing any
* listeners (views) of state changes. * listeners (views) of state changes.
*/ */
@ -42,8 +44,8 @@ class MessageComposerStore extends Store {
__onDispatch(payload) { __onDispatch(payload) {
switch (payload.action) { switch (payload.action) {
case 'content_state': case 'editor_state':
this._contentState(payload); this._editorState(payload);
break; break;
case 'on_logged_out': case 'on_logged_out':
this.reset(); this.reset();
@ -51,18 +53,28 @@ class MessageComposerStore extends Store {
} }
} }
_contentState(payload) { _editorState(payload) {
const editorStateMap = this._state.editorStateMap; const editorStateMap = this._state.editorStateMap;
editorStateMap[payload.room_id] = convertToRaw(payload.content_state); editorStateMap[payload.room_id] = {
localStorage.setItem('content_state', JSON.stringify(editorStateMap)); editor_state: payload.editor_state,
rich_text: payload.rich_text,
};
localStorage.setItem('editor_state', JSON.stringify(editorStateMap));
this._setState({ this._setState({
editorStateMap: editorStateMap, editorStateMap: editorStateMap,
}); });
} }
getContentState(roomId) { getEditorState(roomId) {
return this._state.editorStateMap[roomId] ? const editorStateMap = this._state.editorStateMap;
convertFromRaw(this._state.editorStateMap[roomId]) : null; // const entry = this._state.editorStateMap[roomId];
if (editorStateMap[roomId] && !Value.isValue(editorStateMap[roomId].editor_state)) {
// rehydrate lazily to prevent massive churn at launch and cache it
editorStateMap[roomId].editor_state = Value.fromJSON(editorStateMap[roomId].editor_state);
}
// explicitly don't setState here because the value didn't actually change, we just hydrated it,
// if a listener received an update they too would call this method and have a hydrated Value
return editorStateMap[roomId];
} }
reset() { reset() {

View file

@ -20,7 +20,9 @@ function addTextToDraft(text) {
} }
} }
describe('MessageComposerInput', () => { // FIXME: These tests need to be updated from Draft to Slate.
xdescribe('MessageComposerInput', () => {
let parentDiv = null, let parentDiv = null,
sandbox = null, sandbox = null,
client = null, client = null,
@ -69,7 +71,7 @@ describe('MessageComposerInput', () => {
'mx_MessageComposer_input_markdownIndicator'); 'mx_MessageComposer_input_markdownIndicator');
ReactTestUtils.Simulate.click(indicator); ReactTestUtils.Simulate.click(indicator);
expect(mci.state.isRichtextEnabled).toEqual(false, 'should have changed mode'); expect(mci.state.isRichTextEnabled).toEqual(false, 'should have changed mode');
done(); done();
}); });
}); });