Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Commit

Permalink
Merge pull request #1890 from matrix-org/matthew/slate
Browse files Browse the repository at this point in the history
Replace Draft with Slate
  • Loading branch information
dbkr authored Jul 16, 2018
2 parents 415eef5 + eb497d4 commit d16ac4d
Show file tree
Hide file tree
Showing 42 changed files with 1,396 additions and 1,140 deletions.
1 change: 1 addition & 0 deletions .eslintrc.js
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ module.exports = {
"new-cap": ["warn"],
"key-spacing": ["warn"],
"prefer-const": ["warn"],
"arrow-parens": "off",

// crashes currently: https://github.com/eslint/eslint/issues/6274
"generator-star-spacing": "off",
Expand Down
88 changes: 88 additions & 0 deletions docs/slate-formats.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,6 @@
"classnames": "^2.1.2",
"commonmark": "^0.28.1",
"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",
"file-saver": "^1.3.3",
"filesize": "3.5.6",
Expand All @@ -87,6 +84,10 @@
"react-beautiful-dnd": "^4.0.1",
"react-dom": "^15.6.0",
"react-gemini-scrollbar": "matrix-org/react-gemini-scrollbar#5e97aef",
"slate": "0.33.4",
"slate-react": "^0.12.4",
"slate-html-serializer": "^0.6.1",
"slate-md-serializer": "matrix-org/slate-md-serializer#f7c4ad3",
"sanitize-html": "^1.14.1",
"text-encoding-utf-8": "^1.0.1",
"url": "^0.11.0",
Expand Down
4 changes: 4 additions & 0 deletions res/css/_common.scss
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,10 @@ textarea {
vertical-align: middle;
}

.mx_emojione_selected {
background-color: $accent-color;
}

::-moz-selection {
background-color: $accent-color;
color: $selection-fg-color;
Expand Down
5 changes: 1 addition & 4 deletions res/css/structures/_RoomView.scss
Original file line number Diff line number Diff line change
Expand Up @@ -176,10 +176,7 @@ hr.mx_RoomView_myReadMarker {
z-index: 1000;
overflow: hidden;

-webkit-transition: all .2s ease-out;
-moz-transition: all .2s ease-out;
-ms-transition: all .2s ease-out;
-o-transition: all .2s ease-out;
transition: all .2s ease-out;
}

.mx_RoomView_statusArea_expanded {
Expand Down
4 changes: 4 additions & 0 deletions res/css/views/elements/_RichText.scss
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
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_content .mx_AtRoomPill,
.mx_MessageComposer_input .mx_AtRoomPill {
Expand Down
1 change: 1 addition & 0 deletions res/css/views/rooms/_EventTile.scss
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,7 @@ limitations under the License.
.mx_EventTile_content .markdown-body h2
{
font-size: 1.5em;
border-bottom: none ! important; // override GFM
}

.mx_EventTile_content .markdown-body a {
Expand Down
43 changes: 19 additions & 24 deletions res/css/views/rooms/_MessageComposer.scss
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,29 @@ limitations under the License.
display: flex;
flex-direction: column;
min-height: 60px;
justify-content: center;
justify-content: start;
align-items: flex-start;
font-size: 14px;
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
{
from { background-color: #faa }
Expand All @@ -95,36 +112,14 @@ limitations under the License.
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 {
color: $blockquote-fg-color;
margin: 0 0 16px;
padding: 0 15px;
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;
border-radius: 3px;
padding: 10px;
Expand Down
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
File renamed without changes
75 changes: 39 additions & 36 deletions src/ComposerHistoryManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,70 +15,73 @@ See the License for the specific language governing permissions and
limitations under the License.
*/

import {ContentState, convertToRaw, convertFromRaw} from 'draft-js';
import * as RichText from './RichText';
import Markdown from './Markdown';
import { Value } from 'slate';

import _clamp from 'lodash/clamp';

type MessageFormat = 'html' | 'markdown';
type MessageFormat = 'rich' | 'markdown';

class HistoryItem {

// Keeping message for backwards-compatibility
message: string;
rawContentState: RawDraftContentState;
format: MessageFormat = 'html';
// We store history items in their native format to ensure history is accurate
// and then convert them if our RTE has subsequently changed format.
value: Value;
format: MessageFormat = 'rich';

constructor(contentState: ?ContentState, format: ?MessageFormat) {
this.rawContentState = contentState ? convertToRaw(contentState) : null;
constructor(value: ?Value, format: ?MessageFormat) {
this.value = value;
this.format = format;
}

toContentState(outputFormat: MessageFormat): ContentState {
const contentState = convertFromRaw(this.rawContentState);
if (outputFormat === 'markdown') {
if (this.format === 'html') {
return ContentState.createFromText(RichText.stateToMarkdown(contentState));
}
} else {
if (this.format === 'markdown') {
return RichText.htmlToContentState(new Markdown(contentState.getPlainText()).toHTML());
}
}
// history item has format === outputFormat
return contentState;
static fromJSON(obj: Object): HistoryItem {
return new HistoryItem(
Value.fromJSON(obj.value),
obj.format,
);
}

toJSON(): Object {
return {
value: this.value.toJSON(),
format: this.format,
};
}
}

export default class ComposerHistoryManager {
history: Array<HistoryItem> = [];
prefix: string;
lastIndex: number = 0;
currentIndex: number = 0;
lastIndex: number = 0; // used for indexing the storage
currentIndex: number = 0; // used for indexing the loaded validated history Array

constructor(roomId: string, prefix: string = 'mx_composer_history_') {
this.prefix = prefix + roomId;

// TODO: Performance issues?
let item;
for (; item = sessionStorage.getItem(`${this.prefix}[${this.currentIndex}]`); this.currentIndex++) {
this.history.push(
Object.assign(new HistoryItem(), JSON.parse(item)),
);
try {
this.history.push(
HistoryItem.fromJSON(JSON.parse(item)),
);
} catch (e) {
console.warn("Throwing away unserialisable history", e);
}
}
this.lastIndex = this.currentIndex;
// reset currentIndex to account for any unserialisable history
this.currentIndex = this.history.length;
}

save(contentState: ContentState, format: MessageFormat) {
const item = new HistoryItem(contentState, format);
save(value: Value, format: MessageFormat) {
const item = new HistoryItem(value, format);
this.history.push(item);
this.currentIndex = this.lastIndex + 1;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item));
this.currentIndex = this.history.length;
sessionStorage.setItem(`${this.prefix}[${this.lastIndex++}]`, JSON.stringify(item.toJSON()));
}

getItem(offset: number, format: MessageFormat): ?ContentState {
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.lastIndex - 1);
const item = this.history[this.currentIndex];
return item ? item.toContentState(format) : null;
getItem(offset: number): ?HistoryItem {
this.currentIndex = _clamp(this.currentIndex + offset, 0, this.history.length - 1);
return this.history[this.currentIndex];
}
}
Loading

0 comments on commit d16ac4d

Please sign in to comment.