Merge pull request #2049 from matrix-org/t3chguy/slate_cont2
T3chguy/slate cont2
This commit is contained in:
commit
eb497d442b
15 changed files with 310 additions and 247 deletions
88
docs/slate-formats.md
Normal file
88
docs/slate-formats.md
Normal 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.
|
|
@ -84,7 +84,7 @@
|
||||||
"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": "0.33.4",
|
||||||
"slate-react": "^0.12.4",
|
"slate-react": "^0.12.4",
|
||||||
"slate-html-serializer": "^0.6.1",
|
"slate-html-serializer": "^0.6.1",
|
||||||
"slate-md-serializer": "matrix-org/slate-md-serializer#f7c4ad3",
|
"slate-md-serializer": "matrix-org/slate-md-serializer#f7c4ad3",
|
||||||
|
|
|
@ -51,8 +51,8 @@ class HistoryItem {
|
||||||
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;
|
||||||
|
@ -69,18 +69,19 @@ export default class ComposerHistoryManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.lastIndex = this.currentIndex;
|
this.lastIndex = this.currentIndex;
|
||||||
|
// reset currentIndex to account for any unserialisable history
|
||||||
|
this.currentIndex = this.history.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
save(value: Value, format: MessageFormat) {
|
save(value: Value, format: MessageFormat) {
|
||||||
const item = new HistoryItem(value, 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.toJSON()));
|
sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON()));
|
||||||
}
|
}
|
||||||
|
|
||||||
getItem(offset: number): ?HistoryItem {
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -112,42 +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.
|
||||||
|
|
|
@ -180,14 +180,6 @@ export default class Markdown {
|
||||||
if (is_multi_line(node) && node.next) this.lit('\n\n');
|
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);
|
return renderer.render(this.parsed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -29,9 +29,9 @@ import NotifProvider from './NotifProvider';
|
||||||
import Promise from 'bluebird';
|
import Promise from 'bluebird';
|
||||||
|
|
||||||
export type SelectionRange = {
|
export type SelectionRange = {
|
||||||
beginning: boolean,
|
beginning: boolean, // whether the selection is in the first block of the editor or not
|
||||||
start: number,
|
start: number, // byte offset relative to the start anchor of the current editor selection.
|
||||||
end: number
|
end: number, // byte offset relative to the end anchor of the current editor selection.
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Completion = {
|
export type Completion = {
|
||||||
|
|
|
@ -43,7 +43,7 @@ export default class CommandProvider extends AutocompleteProvider {
|
||||||
|
|
||||||
let matches = [];
|
let matches = [];
|
||||||
// check if the full match differs from the first word (i.e. returns false if the command has args)
|
// 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 `/`
|
||||||
if (CommandMap[name]) {
|
if (CommandMap[name]) {
|
||||||
|
|
|
@ -111,7 +111,7 @@ export default class UserProvider extends AutocompleteProvider {
|
||||||
// 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)', ''),
|
||||||
completionId: user.userId,
|
completionId: user.userId,
|
||||||
suffix: (selection.beginning && range.start === 0) ? ': ' : ' ',
|
suffix: (selection.beginning && selection.start === 0) ? ': ' : ' ',
|
||||||
href: makeUserPermalink(user.userId),
|
href: makeUserPermalink(user.userId),
|
||||||
component: (
|
component: (
|
||||||
<PillCompletion
|
<PillCompletion
|
||||||
|
|
|
@ -220,7 +220,8 @@ export default class ContextualMenu extends React.Component {
|
||||||
{ chevron }
|
{ chevron }
|
||||||
<ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} />
|
<ElementClass {...props} onFinished={props.closeMenu} onResize={props.windowResize} />
|
||||||
</div>
|
</div>
|
||||||
{ props.hasBackground && <div className="mx_ContextualMenu_background" onClick={props.closeMenu} onContextMenu={this.onContextMenu} /> }
|
{ props.hasBackground && <div className="mx_ContextualMenu_background"
|
||||||
|
onClick={props.closeMenu} onContextMenu={this.onContextMenu} /> }
|
||||||
<style>{ chevronCSS }</style>
|
<style>{ chevronCSS }</style>
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -56,7 +56,7 @@ const stateEventTileTypes = {
|
||||||
'm.room.topic': 'messages.TextualEvent',
|
'm.room.topic': 'messages.TextualEvent',
|
||||||
'm.room.power_levels': 'messages.TextualEvent',
|
'm.room.power_levels': 'messages.TextualEvent',
|
||||||
'm.room.pinned_events': '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',
|
'im.vector.modular.widgets': 'messages.TextualEvent',
|
||||||
};
|
};
|
||||||
|
|
|
@ -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);
|
||||||
|
@ -322,18 +333,17 @@ export default class MessageComposer extends React.Component {
|
||||||
let formatBar;
|
let formatBar;
|
||||||
if (this.state.showFormatting && this.state.inputState.isRichTextEnabled) {
|
if (this.state.showFormatting && this.state.inputState.isRichTextEnabled) {
|
||||||
const {marks, blockType} = this.state.inputState;
|
const {marks, blockType} = this.state.inputState;
|
||||||
const formatButtons = ["bold", "italic", "deleted", "underlined", "inline-code", "block-quote", "bulleted-list", "numbered-list"].map(
|
const formatButtons = formatButtonList.map((name) => {
|
||||||
(name) => {
|
const active = marks.some(mark => mark.type === name) || blockType === name;
|
||||||
const active = marks.some(mark => mark.type === name) || blockType === name;
|
const suffix = active ? '-on' : '';
|
||||||
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}
|
title={_t(name)}
|
||||||
title={_t(name)}
|
onMouseDown={onFormatButtonClicked}
|
||||||
onMouseDown={onFormatButtonClicked}
|
key={name}
|
||||||
key={name}
|
src={`img/button-text-${name}${suffix}.svg`}
|
||||||
src={`img/button-text-${name}${suffix}.svg`}
|
height="17" />;
|
||||||
height="17" />;
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -21,17 +21,14 @@ import type SyntheticKeyboardEvent from 'react/lib/SyntheticKeyboardEvent';
|
||||||
|
|
||||||
import { Editor } from 'slate-react';
|
import { Editor } from 'slate-react';
|
||||||
import { getEventTransfer } 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 Html from 'slate-html-serializer';
|
||||||
import Md from 'slate-md-serializer';
|
import Md from 'slate-md-serializer';
|
||||||
import Plain from 'slate-plain-serializer';
|
import Plain from 'slate-plain-serializer';
|
||||||
import PlainWithPillsSerializer from "../../../autocomplete/PlainWithPillsSerializer";
|
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 classNames from 'classnames';
|
||||||
import Promise from 'bluebird';
|
import Promise from 'bluebird';
|
||||||
|
|
||||||
|
@ -54,7 +51,7 @@ import Markdown from '../../../Markdown';
|
||||||
import ComposerHistoryManager from '../../../ComposerHistoryManager';
|
import ComposerHistoryManager from '../../../ComposerHistoryManager';
|
||||||
import MessageComposerStore from '../../../stores/MessageComposerStore';
|
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');
|
const REGEX_MATRIXTO_MARKDOWN_GLOBAL = new RegExp(MATRIXTO_MD_LINK_PATTERN, 'g');
|
||||||
|
|
||||||
import {asciiRegexp, unicodeRegexp, shortnameToUnicode, emojioneList, asciiList, mapUnicodeToShort, toShort} from 'emojione';
|
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
|
* The textInput part of the MessageComposer
|
||||||
*/
|
*/
|
||||||
|
@ -146,29 +152,18 @@ export default class MessageComposerInput extends React.Component {
|
||||||
|
|
||||||
Analytics.setRichtextMode(isRichTextEnabled);
|
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,
|
|
||||||
|
|
||||||
// whether there were any completions
|
|
||||||
someCompletions: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
this.client = MatrixClientPeg.get();
|
this.client = MatrixClientPeg.get();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// 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.plainWithMdPills = new PlainWithPillsSerializer({ pillFormat: 'md' });
|
||||||
this.plainWithIdPills = new PlainWithPillsSerializer({ pillFormat: 'id' });
|
this.plainWithIdPills = new PlainWithPillsSerializer({ pillFormat: 'id' });
|
||||||
this.plainWithPlainPills = new PlainWithPillsSerializer({ pillFormat: 'plain' });
|
this.plainWithPlainPills = new PlainWithPillsSerializer({ pillFormat: 'plain' });
|
||||||
|
@ -176,18 +171,35 @@ export default class MessageComposerInput extends React.Component {
|
||||||
this.md = new Md({
|
this.md = new Md({
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
|
// if serialize returns undefined it falls through to the default hardcoded
|
||||||
|
// serialization rules
|
||||||
serialize: (obj, children) => {
|
serialize: (obj, children) => {
|
||||||
if (obj.object === 'inline') {
|
if (obj.object !== 'inline') return;
|
||||||
switch (obj.type) {
|
switch (obj.type) {
|
||||||
case 'pill':
|
case 'pill':
|
||||||
return `[${ obj.data.get('completion') }](${ obj.data.get('href') })`;
|
return `[${ obj.data.get('completion') }](${ obj.data.get('href') })`;
|
||||||
case 'emoji':
|
case 'emoji':
|
||||||
return obj.data.get('emojiUnicode');
|
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 `<u>${ children }</u>`;
|
||||||
|
case 'deleted':
|
||||||
|
return `<del>${ children }</del>`;
|
||||||
|
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({
|
this.html = new Html({
|
||||||
|
@ -278,20 +290,46 @@ export default class MessageComposerInput extends React.Component {
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
this.suppressAutoComplete = false;
|
const savedState = MessageComposerStore.getEditorState(this.props.room.roomId);
|
||||||
this.direction = '';
|
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:
|
* "Does the right thing" to create an Editor value, based on:
|
||||||
* - whether we've got rich text mode enabled
|
* - whether we've got rich text mode enabled
|
||||||
* - contentState was passed in
|
* - 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 (editorState instanceof Value) {
|
||||||
|
if (wantRichText && !wasRichText) {
|
||||||
|
return this.mdToRichEditorState(editorState);
|
||||||
|
}
|
||||||
|
if (wasRichText && !wantRichText) {
|
||||||
|
return this.richToMdEditorState(editorState);
|
||||||
|
}
|
||||||
return editorState;
|
return editorState;
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
// ...or create a new one.
|
// ...or create a new one.
|
||||||
return Plain.deserialize('', { defaultBlock: DEFAULT_NODE });
|
return Plain.deserialize('', { defaultBlock: DEFAULT_NODE });
|
||||||
}
|
}
|
||||||
|
@ -299,7 +337,7 @@ export default class MessageComposerInput extends React.Component {
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
this.dispatcherRef = dis.register(this.onAction);
|
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() {
|
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?
|
// If so, what should be the format, and how do we differentiate it from replies?
|
||||||
|
|
||||||
const quote = Block.create('block-quote');
|
const quote = Block.create('block-quote');
|
||||||
if (this.state.isRichTextEnabled) {
|
if (this.state.isRichTextEnabled) {
|
||||||
let change = editorState.change();
|
let change = editorState.change();
|
||||||
if (editorState.anchorText.text === '' && editorState.anchorBlock.nodes.size === 1) {
|
if (editorState.anchorText.text === '' && editorState.anchorBlock.nodes.size === 1) {
|
||||||
// replace the current block rather than split the block
|
// replace the current block rather than split the block
|
||||||
|
@ -360,7 +398,6 @@ export default class MessageComposerInput extends React.Component {
|
||||||
let fragmentChange = fragment.change();
|
let fragmentChange = fragment.change();
|
||||||
fragmentChange.moveToRangeOf(fragment.document)
|
fragmentChange.moveToRangeOf(fragment.document)
|
||||||
.wrapBlock(quote);
|
.wrapBlock(quote);
|
||||||
//.setBlocks('block-quote');
|
|
||||||
|
|
||||||
// FIXME: handle pills and use commonmark rather than md-serialize
|
// FIXME: handle pills and use commonmark rather than md-serialize
|
||||||
const md = this.md.serialize(fragmentChange.value);
|
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;
|
let editorState = change.value;
|
||||||
|
|
||||||
if (this.direction !== '') {
|
if (this.direction !== '') {
|
||||||
const focusedNode = editorState.focusInline || editorState.focusText;
|
const focusedNode = editorState.focusInline || editorState.focusText;
|
||||||
if (focusedNode.isVoid) {
|
if (focusedNode.isVoid) {
|
||||||
|
// XXX: does this work in RTL?
|
||||||
|
const edge = this.direction === 'Previous' ? 'End' : 'Start';
|
||||||
if (editorState.isCollapsed) {
|
if (editorState.isCollapsed) {
|
||||||
change = change[`collapseToEndOf${ this.direction }Text`]();
|
change = change[`collapseTo${ edge }Of${ this.direction }Text`]();
|
||||||
}
|
} else {
|
||||||
else {
|
|
||||||
const block = this.direction === 'Previous' ? editorState.previousText : editorState.nextText;
|
const block = this.direction === 'Previous' ? editorState.previousText : editorState.nextText;
|
||||||
if (block) {
|
if (block) {
|
||||||
change = change.moveFocusToEndOf(block)
|
change = change[`moveFocusTo${ edge }Of`](block);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
editorState = change.value;
|
editorState = change.value;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// when selection changes hide the autocomplete
|
||||||
|
if (!rangeEquals(this.state.editorState.selection, editorState.selection)) {
|
||||||
|
this.autocomplete.hide();
|
||||||
|
}
|
||||||
|
|
||||||
if (!editorState.document.isEmpty) {
|
if (!editorState.document.isEmpty) {
|
||||||
this.onTypingActivity();
|
this.onTypingActivity();
|
||||||
} else {
|
} else {
|
||||||
this.onFinishedTyping();
|
this.onFinishedTyping();
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
// XXX: what was this ever doing?
|
|
||||||
if (!state.hasOwnProperty('originalEditorState')) {
|
|
||||||
state.originalEditorState = null;
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
if (editorState.startText !== null) {
|
if (editorState.startText !== null) {
|
||||||
const text = editorState.startText.text;
|
const text = editorState.startText.text;
|
||||||
const currentStartOffset = editorState.startOffset;
|
const currentStartOffset = editorState.startOffset;
|
||||||
|
@ -501,9 +536,7 @@ export default class MessageComposerInput extends React.Component {
|
||||||
}
|
}
|
||||||
|
|
||||||
// emojioneify any emoji
|
// emojioneify any emoji
|
||||||
|
editorState.document.getTexts().forEach(node => {
|
||||||
// XXX: is getTextsAsArray a private API?
|
|
||||||
editorState.document.getTextsAsArray().forEach(node => {
|
|
||||||
if (node.text !== '' && HtmlUtils.containsEmoji(node.text)) {
|
if (node.text !== '' && HtmlUtils.containsEmoji(node.text)) {
|
||||||
let match;
|
let match;
|
||||||
while ((match = EMOJI_REGEX.exec(node.text)) !== null) {
|
while ((match = EMOJI_REGEX.exec(node.text)) !== null) {
|
||||||
|
@ -535,36 +568,6 @@ export default class MessageComposerInput extends React.Component {
|
||||||
editorState = change.value;
|
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) {
|
if (this.props.onInputStateChanged && editorState.blocks.size > 0) {
|
||||||
let blockType = editorState.blocks.first().type;
|
let blockType = editorState.blocks.first().type;
|
||||||
// console.log("onInputStateChanged; current block type is " + blockType + " and marks are " + editorState.activeMarks);
|
// 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({
|
dis.dispatch({
|
||||||
action: 'editor_state',
|
action: 'editor_state',
|
||||||
room_id: this.props.room.roomId,
|
room_id: this.props.room.roomId,
|
||||||
|
rich_text: this.state.isRichTextEnabled,
|
||||||
editor_state: editorState,
|
editor_state: editorState,
|
||||||
});
|
});
|
||||||
|
|
||||||
/* Since a modification was made, set originalEditorState to null, since newState is now our original */
|
|
||||||
this.setState({
|
this.setState({
|
||||||
editorState,
|
editorState,
|
||||||
originalEditorState: originalEditorState || null
|
originalEditorState: originalEditorState || null
|
||||||
|
@ -672,7 +675,7 @@ export default class MessageComposerInput extends React.Component {
|
||||||
|
|
||||||
hasMark = type => {
|
hasMark = type => {
|
||||||
const { editorState } = this.state
|
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 => {
|
hasBlock = type => {
|
||||||
const { editorState } = this.state
|
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;
|
this.suppressAutoComplete = false;
|
||||||
|
|
||||||
|
@ -702,22 +705,6 @@ export default class MessageComposerInput extends React.Component {
|
||||||
this.direction = '';
|
this.direction = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isOnlyCtrlOrCmdKeyEvent(ev)) {
|
|
||||||
const ctrlCmdCommand = {
|
|
||||||
// C-m => Toggles between rich text and markdown modes
|
|
||||||
[KeyCode.KEY_M]: 'toggle-mode',
|
|
||||||
[KeyCode.KEY_B]: 'bold',
|
|
||||||
[KeyCode.KEY_I]: 'italic',
|
|
||||||
[KeyCode.KEY_U]: 'underlined',
|
|
||||||
[KeyCode.KEY_J]: 'inline-code',
|
|
||||||
}[ev.keyCode];
|
|
||||||
|
|
||||||
if (ctrlCmdCommand) {
|
|
||||||
return this.handleKeyCommand(ctrlCmdCommand);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (ev.keyCode) {
|
switch (ev.keyCode) {
|
||||||
case KeyCode.ENTER:
|
case KeyCode.ENTER:
|
||||||
return this.handleReturn(ev, change);
|
return this.handleReturn(ev, change);
|
||||||
|
@ -731,21 +718,53 @@ export default class MessageComposerInput extends React.Component {
|
||||||
return this.onTab(ev);
|
return this.onTab(ev);
|
||||||
case KeyCode.ESCAPE:
|
case KeyCode.ESCAPE:
|
||||||
return this.onEscape(ev);
|
return this.onEscape(ev);
|
||||||
default:
|
case KeyCode.SPACE:
|
||||||
// don't intercept it
|
return this.onSpace(ev, change);
|
||||||
return;
|
}
|
||||||
|
|
||||||
|
if (isOnlyCtrlOrCmdKeyEvent(ev)) {
|
||||||
|
const ctrlCmdCommand = {
|
||||||
|
// C-m => Toggles between rich text and markdown modes
|
||||||
|
[KeyCode.KEY_M]: 'toggle-mode',
|
||||||
|
[KeyCode.KEY_B]: 'bold',
|
||||||
|
[KeyCode.KEY_I]: 'italic',
|
||||||
|
[KeyCode.KEY_U]: 'underlined',
|
||||||
|
[KeyCode.KEY_J]: 'inline-code',
|
||||||
|
}[ev.keyCode];
|
||||||
|
|
||||||
|
if (ctrlCmdCommand) {
|
||||||
|
return this.handleKeyCommand(ctrlCmdCommand);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
onBackspace = (ev: Event, change: Change): Change => {
|
onSpace = (ev: KeyboardEvent, change: Change): Change => {
|
||||||
if (ev.ctrlKey || ev.metaKey || ev.altKey || ev.ctrlKey || ev.shiftKey) {
|
if (ev.metaKey || ev.altKey || ev.shiftKey || ev.ctrlKey) {
|
||||||
return;
|
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: 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) {
|
if (this.state.isRichTextEnabled) {
|
||||||
// let backspace exit lists
|
// let backspace exit lists
|
||||||
const isList = this.hasBlock('list-item');
|
const isList = this.hasBlock('list-item');
|
||||||
const { editorState } = this.state;
|
|
||||||
|
|
||||||
if (isList && editorState.anchorOffset == 0) {
|
if (isList && editorState.anchorOffset == 0) {
|
||||||
change
|
change
|
||||||
|
@ -805,7 +824,7 @@ export default class MessageComposerInput extends React.Component {
|
||||||
// Handle the extra wrapping required for list buttons.
|
// Handle the extra wrapping required for list buttons.
|
||||||
const isList = this.hasBlock('list-item');
|
const isList = this.hasBlock('list-item');
|
||||||
const isType = editorState.blocks.some(block => {
|
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) {
|
if (isList && isType) {
|
||||||
|
@ -816,7 +835,7 @@ export default class MessageComposerInput extends React.Component {
|
||||||
} else if (isList) {
|
} else if (isList) {
|
||||||
change
|
change
|
||||||
.unwrapBlock(
|
.unwrapBlock(
|
||||||
type == 'bulleted-list' ? 'numbered-list' : 'bulleted-list'
|
type === 'bulleted-list' ? 'numbered-list' : 'bulleted-list'
|
||||||
)
|
)
|
||||||
.wrapBlock(type);
|
.wrapBlock(type);
|
||||||
} else {
|
} else {
|
||||||
|
@ -986,7 +1005,7 @@ export default class MessageComposerInput extends React.Component {
|
||||||
let contentHTML;
|
let contentHTML;
|
||||||
|
|
||||||
// only look for commands if the first block contains simple unformatted text
|
// 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;
|
let cmd, commandText;
|
||||||
const firstChild = editorState.document.nodes.get(0);
|
const firstChild = editorState.document.nodes.get(0);
|
||||||
const firstGrandChild = firstChild && firstChild.nodes.get(0);
|
const firstGrandChild = firstChild && firstChild.nodes.get(0);
|
||||||
|
@ -995,7 +1014,7 @@ export default class MessageComposerInput extends React.Component {
|
||||||
firstGrandChild.text[0] === '/')
|
firstGrandChild.text[0] === '/')
|
||||||
{
|
{
|
||||||
commandText = this.plainWithIdPills.serialize(editorState);
|
commandText = this.plainWithIdPills.serialize(editorState);
|
||||||
cmd = SlashCommands.processInput(this.props.room.roomId, commandText);
|
cmd = processCommandInput(this.props.room.roomId, commandText);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cmd) {
|
if (cmd) {
|
||||||
|
@ -1067,8 +1086,8 @@ export default class MessageComposerInput extends React.Component {
|
||||||
// didn't contain any formatting in the first place...
|
// didn't contain any formatting in the first place...
|
||||||
contentText = mdWithPills.toPlaintext();
|
contentText = mdWithPills.toPlaintext();
|
||||||
} else {
|
} else {
|
||||||
// to avoid ugliness clients which can't parse HTML we don't send pills
|
// to avoid ugliness on clients which ignore the HTML body we don't
|
||||||
// in the plaintext body.
|
// send pills in the plaintext body.
|
||||||
contentText = this.plainWithPlainPills.serialize(editorState);
|
contentText = this.plainWithPlainPills.serialize(editorState);
|
||||||
contentHTML = mdWithPills.toHTML();
|
contentHTML = mdWithPills.toHTML();
|
||||||
}
|
}
|
||||||
|
@ -1147,41 +1166,18 @@ export default class MessageComposerInput extends React.Component {
|
||||||
|
|
||||||
// Select history only if we are not currently auto-completing
|
// Select history only if we are not currently auto-completing
|
||||||
if (this.autocomplete.state.completionList.length === 0) {
|
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
|
// selection must be collapsed
|
||||||
// input box by just looking at the position of the plain old DOM selection.
|
if (!selection.isCollapsed) return;
|
||||||
const selection = window.getSelection();
|
const document = this.state.editorState.document;
|
||||||
const range = selection.getRangeAt(0);
|
|
||||||
const cursorRect = range.getBoundingClientRect();
|
|
||||||
|
|
||||||
const editorNode = ReactDOM.findDOMNode(this.refs.editor);
|
// and we must be at the edge of the document (up=start, down=end)
|
||||||
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;
|
|
||||||
if (up) {
|
if (up) {
|
||||||
const scrollCorrection = editorNode.scrollTop;
|
if (!selection.isAtStartOf(document)) return;
|
||||||
const distanceFromTop = cursorRect.top - editorRect.top + scrollCorrection;
|
} else {
|
||||||
console.log(`Cursor distance from editor top is ${distanceFromTop}`);
|
if (!selection.isAtEndOf(document)) return;
|
||||||
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 (!navigateHistory) return;
|
|
||||||
|
|
||||||
const selected = this.selectHistory(up);
|
const selected = this.selectHistory(up);
|
||||||
if (selected) {
|
if (selected) {
|
||||||
|
@ -1232,11 +1228,8 @@ export default class MessageComposerInput extends React.Component {
|
||||||
// Move selection to the end of the selected history
|
// Move selection to the end of the selected history
|
||||||
const change = editorState.change().collapseToEndOf(editorState.document);
|
const change = editorState.change().collapseToEndOf(editorState.document);
|
||||||
|
|
||||||
// XXX: should we be calling this.onChange(change) now?
|
// We don't call this.onChange(change) now, as fixups on stuff like emoji
|
||||||
// Answer: yes, if we want it to do any of the fixups on stuff like emoji.
|
// should already have been done and persisted in the history.
|
||||||
// however, this should already have been done and persisted in the history,
|
|
||||||
// so shouldn't be necessary.
|
|
||||||
|
|
||||||
editorState = change.value;
|
editorState = change.value;
|
||||||
|
|
||||||
this.suppressAutoComplete = true;
|
this.suppressAutoComplete = true;
|
||||||
|
@ -1339,6 +1332,8 @@ export default class MessageComposerInput extends React.Component {
|
||||||
.insertText(suffix)
|
.insertText(suffix)
|
||||||
.focus();
|
.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;
|
editorState = change.value;
|
||||||
|
|
||||||
this.onChange(change, activeEditorState);
|
this.onChange(change, activeEditorState);
|
||||||
|
@ -1437,10 +1432,11 @@ export default class MessageComposerInput extends React.Component {
|
||||||
};
|
};
|
||||||
|
|
||||||
onFormatButtonClicked = (name, e) => {
|
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
|
// 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
|
// 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') {
|
if (document.activeElement && document.activeElement.className !== 'mx_MessageComposer_editor') {
|
||||||
this.refs.editor.focus();
|
this.refs.editor.focus();
|
||||||
setTimeout(()=>{
|
setTimeout(()=>{
|
||||||
|
|
|
@ -406,6 +406,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",
|
||||||
|
@ -430,14 +438,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.",
|
||||||
|
@ -772,7 +772,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",
|
||||||
|
|
|
@ -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,6 +15,7 @@ limitations under the License.
|
||||||
*/
|
*/
|
||||||
import dis from '../dispatcher';
|
import dis from '../dispatcher';
|
||||||
import { Store } from 'flux/utils';
|
import { Store } from 'flux/utils';
|
||||||
|
import { Value } from 'slate';
|
||||||
|
|
||||||
const INITIAL_STATE = {
|
const INITIAL_STATE = {
|
||||||
// a map of room_id to rich text editor composer state
|
// a map of room_id to rich text editor composer state
|
||||||
|
@ -54,7 +55,10 @@ class MessageComposerStore extends Store {
|
||||||
|
|
||||||
_editorState(payload) {
|
_editorState(payload) {
|
||||||
const editorStateMap = this._state.editorStateMap;
|
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));
|
localStorage.setItem('editor_state', JSON.stringify(editorStateMap));
|
||||||
this._setState({
|
this._setState({
|
||||||
editorStateMap: editorStateMap,
|
editorStateMap: editorStateMap,
|
||||||
|
@ -62,7 +66,15 @@ class MessageComposerStore extends Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
getEditorState(roomId) {
|
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() {
|
reset() {
|
||||||
|
|
|
@ -10,7 +10,6 @@ const MessageComposerInput = sdk.getComponent('views.rooms.MessageComposerInput'
|
||||||
import MatrixClientPeg from '../../../../src/MatrixClientPeg';
|
import MatrixClientPeg from '../../../../src/MatrixClientPeg';
|
||||||
import RoomMember from 'matrix-js-sdk';
|
import RoomMember from 'matrix-js-sdk';
|
||||||
|
|
||||||
/*
|
|
||||||
function addTextToDraft(text) {
|
function addTextToDraft(text) {
|
||||||
const components = document.getElementsByClassName('public-DraftEditor-content');
|
const components = document.getElementsByClassName('public-DraftEditor-content');
|
||||||
if (components && components.length) {
|
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,
|
let parentDiv = null,
|
||||||
sandbox = null,
|
sandbox = null,
|
||||||
client = 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].body).toEqual('[Click here](https://some.lovely.url)');
|
||||||
expect(spy.args[0][1].formatted_body).toEqual('<a href="https://some.lovely.url">Click here</a>');
|
expect(spy.args[0][1].formatted_body).toEqual('<a href="https://some.lovely.url">Click here</a>');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
*/
|
|
Loading…
Add table
Add a link
Reference in a new issue