Rich Text Editor (WIP) x4

This commit is contained in:
mgabdev
2020-06-17 16:27:58 -04:00
parent 1a506327db
commit b93ecc7095
11 changed files with 384 additions and 10 deletions

View File

@@ -17,6 +17,7 @@ export default class AutosuggestTextbox extends ImmutablePureComponent {
static propTypes = {
value: PropTypes.string,
valueMarkdown: PropTypes.string,
suggestions: ImmutablePropTypes.list,
disabled: PropTypes.bool,
placeholder: PropTypes.string,
@@ -214,6 +215,7 @@ export default class AutosuggestTextbox extends ImmutablePureComponent {
onKeyUp,
autoFocus,
children,
valueMarkdown,
className,
id,
maxLength,
@@ -280,6 +282,7 @@ export default class AutosuggestTextbox extends ImmutablePureComponent {
placeholder={placeholder}
autoFocus={autoFocus}
value={value}
valueMarkdown={valueMarkdown}
onChange={this.onChange}
onKeyDown={this.onKeyDown}
onKeyUp={onKeyUp}

View File

@@ -4,9 +4,11 @@ import {
CompositeDecorator,
RichUtils,
convertToRaw,
convertFromRaw,
ContentState,
} from 'draft-js'
import draftToMarkdown from '../features/ui/util/draft-to-markdown'
import markdownToDraft from '../features/ui/util/markdown-to-draft'
import { urlRegex } from '../features/ui/util/url_regex'
import classNames from 'classnames/bind'
import RichTextEditorBar from './rich_text_editor_bar'
@@ -81,6 +83,7 @@ export default class Composer extends PureComponent {
disabled: PropTypes.bool,
placeholder: PropTypes.string,
value: PropTypes.string,
valueMarkdown: PropTypes.string,
onChange: PropTypes.func,
onKeyDown: PropTypes.func,
onKeyUp: PropTypes.func,
@@ -95,6 +98,24 @@ export default class Composer extends PureComponent {
plainText: this.props.value,
}
componentDidMount() {
if (this.props.valueMarkdown) {
const rawData = markdownToDraft(this.props.valueMarkdown)
const contentState = convertFromRaw(rawData)
const editorState = EditorState.createWithContent(contentState)
this.setState({
editorState,
plainText: this.props.value,
})
} else if (this.props.value) {
editorState = EditorState.push(this.state.editorState, ContentState.createFromText(this.props.value))
this.setState({
editorState,
plainText: this.props.value,
})
}
}
componentDidUpdate() {
if (this.state.plainText !== this.props.value) {
let editorState

View File

@@ -45,7 +45,7 @@ class ComposeForm extends ImmutablePureComponent {
static contextTypes = {
router: PropTypes.object,
}
state = {
composeFocused: false,
}
@@ -55,6 +55,7 @@ class ComposeForm extends ImmutablePureComponent {
edit: PropTypes.bool,
isMatch: PropTypes.bool,
text: PropTypes.string.isRequired,
markdown: PropTypes.string,
suggestions: ImmutablePropTypes.list,
account: ImmutablePropTypes.map.isRequired,
status: ImmutablePropTypes.map,
@@ -133,9 +134,9 @@ class ComposeForm extends ImmutablePureComponent {
handleSubmit = () => {
// if (this.props.text !== this.autosuggestTextarea.textbox.value) {
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text
// this.props.onChange(this.autosuggestTextarea.textbox.value);
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
// Update the state to match the current text
// this.props.onChange(this.autosuggestTextarea.textbox.value);
// }
// Submit disabled:
@@ -244,6 +245,7 @@ class ComposeForm extends ImmutablePureComponent {
isSubmitting,
selectedGifSrc,
} = this.props
const disabled = isSubmitting
const text = [this.props.spoilerText, countableText(this.props.text)].join('');
const disabledButton = disabled || isUploading || isChangingUpload || length(text) > MAX_POST_CHARACTER_COUNT || (length(text) !== 0 && length(text.trim()) === 0 && !anyMedia);
@@ -325,9 +327,9 @@ class ComposeForm extends ImmutablePureComponent {
<div className={actionsContainerClasses}>
<div className={[_s.default, _s.flexRow, _s.mrAuto].join(' ')}>
{ /* <EmojiPickerButton small={shouldCondense} isMatch={isMatch} /> */ }
{ /* <EmojiPickerButton small={shouldCondense} isMatch={isMatch} /> */}
{ /* <UploadButton small={shouldCondense} /> */ }
{ /* <UploadButton small={shouldCondense} /> */}
<div className={commentPublishBtnClasses}>
<Button
@@ -400,6 +402,7 @@ class ComposeForm extends ImmutablePureComponent {
placeholder={intl.formatMessage((shouldCondense || !!reduxReplyToId) && isMatch ? messages.commentPlaceholder : messages.placeholder)}
disabled={disabled}
value={this.props.text}
valueMarkdown={this.props.markdown}
onChange={this.handleChange}
suggestions={this.props.suggestions}
onKeyDown={this.handleKeyDown}

View File

@@ -39,6 +39,8 @@ const mapStateToProps = (state, props) => {
// console.log("isMatch:", isMatch, reduxReplyToId, replyToId, state.getIn(['compose', 'text']))
// console.log("reduxReplyToId:", reduxReplyToId, isModalOpen, isStandalone)
const edit = state.getIn(['compose', 'id'])
if (!isMatch) {
return {
isMatch,
@@ -46,6 +48,7 @@ const mapStateToProps = (state, props) => {
reduxReplyToId,
edit: null,
text: '',
markdown: null,
suggestions: ImmutableList(),
spoiler: false,
spoilerText: '',
@@ -65,13 +68,14 @@ const mapStateToProps = (state, props) => {
selectedGifSrc: null,
}
}
return {
isMatch,
isModalOpen,
reduxReplyToId,
edit: state.getIn(['compose', 'id']) !== null,
text: state.getIn(['compose', 'text']),
markdown: state.getIn(['compose', 'markdown']),
suggestions: state.getIn(['compose', 'suggestions']),
spoiler: state.getIn(['compose', 'spoiler']),
spoilerText: state.getIn(['compose', 'spoiler_text']),

View File

@@ -0,0 +1,312 @@
// https://raw.githubusercontent.com/Rosey/markdown-draft-js/main/src/markdown-to-draft.js
import { Remarkable } from 'remarkable';
const TRAILING_NEW_LINE = /\n$/;
// In DraftJS, string lengths are calculated differently than in JS itself (due
// to surrogate pairs). Instead of importing the entire UnicodeUtils file from
// FBJS, we use a simpler alternative, in the form of `Array.from`.
//
// Alternative: const { strlen } = require('fbjs/lib/UnicodeUtils');
function strlen(str) {
return Array.from(str).length;
}
// Block level items, key is Remarkable's key for them, value returned is
// A function that generates the raw draftjs key and block data.
//
// Why a function? Because in some cases (headers) we need additional information
// before we can determine the exact key to return. And blocks may also return data
const DefaultBlockTypes = {
paragraph_open: function (item) {
return {
type: 'unstyled',
text: '',
entityRanges: [],
inlineStyleRanges: []
};
},
blockquote_open: function (item) {
return {
type: 'blockquote',
text: ''
};
},
ordered_list_item_open: function () {
return {
type: 'ordered-list-item',
text: ''
};
},
unordered_list_item_open: function () {
return {
type: 'unordered-list-item',
text: ''
};
},
fence: function (item) {
return {
type: 'code-block',
data: {
language: item.params || ''
},
text: (item.content || '').replace(TRAILING_NEW_LINE, ''), // remarkable seems to always append an erronious trailing newline to its codeblock content, so we need to trim it out.
entityRanges: [],
inlineStyleRanges: []
};
},
heading_open: function (item) {
var type = 'header-' + ({
1: 'one',
2: 'two',
3: 'three',
4: 'four',
5: 'five',
6: 'six'
})[item.hLevel];
return {
type: type,
text: ''
};
}
};
// Entity types. These are things like links or images that require
// additional data and will be added to the `entityMap`
// again. In this case, key is remarkable key, value is
// meethod that returns the draftjs key + any data needed.
const DefaultBlockEntities = {
link_open: function (item) {
return {
type: 'LINK',
mutability: 'MUTABLE',
data: {
url: item.href,
href: item.href
}
};
}
};
// Entity styles. Simple Inline styles that aren't added to entityMap
// key is remarkable key, value is draftjs raw key
const DefaultBlockStyles = {
strong_open: 'BOLD',
em_open: 'ITALIC',
code: 'CODE'
};
// Key generator for entityMap items
var idCounter = -1;
function generateUniqueKey() {
idCounter++;
return idCounter;
}
/*
* Handle inline content in a block level item
* parses for BlockEntities (links, images) and BlockStyles (em, strong)
* doesn't handle block level items (blockquote, ordered list, etc)
*
* @param <Object> inlineItem - single object from remarkable data representation of markdown
* @param <Object> BlockEntities - key-value object of mappable block entity items. Passed in as param so users can include their own custom stuff
* @param <Object> BlockStyles - key-value object of mappable block styles items. Passed in as param so users can include their own custom stuff
*
* @return <Object>
* content: Entire text content for the inline item,
* blockEntities: New block eneities to be added to global block entity map
* blockEntityRanges: block-level representation of block entities including key to access the block entity from the global map
* blockStyleRanges: block-level representation of styles (eg strong, em)
*/
function parseInline(inlineItem, BlockEntities, BlockStyles) {
var content = '', blockEntities = {}, blockEntityRanges = [], blockInlineStyleRanges = [];
inlineItem.children.forEach(function (child) {
if (child.type === 'text') {
content += child.content;
} else if (child.type === 'softbreak') {
content += '\n';
} else if (child.type === 'hardbreak') {
content += '\n';
} else if (BlockStyles[child.type]) {
var key = generateUniqueKey();
var styleBlock = {
offset: strlen(content) || 0,
length: 0,
style: BlockStyles[child.type]
};
// Edge case hack because code items don't have inline content or open/close, unlike everything else
if (child.type === 'code') {
styleBlock.length = strlen(child.content);
content += child.content;
}
blockInlineStyleRanges.push(styleBlock);
} else if (BlockEntities[child.type]) {
var key = generateUniqueKey();
blockEntities[key] = BlockEntities[child.type](child);
blockEntityRanges.push({
offset: strlen(content) || 0,
length: 0,
key: key
});
} else if (child.type.indexOf('_close') !== -1 && BlockEntities[child.type.replace('_close', '_open')]) {
blockEntityRanges[blockEntityRanges.length - 1].length = strlen(content) - blockEntityRanges[blockEntityRanges.length - 1].offset;
} else if (child.type.indexOf('_close') !== -1 && BlockStyles[child.type.replace('_close', '_open')]) {
var type = BlockStyles[child.type.replace('_close', '_open')]
blockInlineStyleRanges = blockInlineStyleRanges
.map(style => {
if (style.length === 0 && style.style === type) {
style.length = strlen(content) - style.offset;
}
return style;
});
}
});
return {content, blockEntities, blockEntityRanges, blockInlineStyleRanges};
}
/**
* Convert markdown into raw draftjs object
*
* @param {String} markdown - markdown to convert into raw draftjs object
* @param {Object} options - optional additional data, see readme for what options can be passed in.
*
* @return {Object} rawDraftObject
**/
function markdownToDraft(string, options = {}) {
const remarkablePreset = options.remarkablePreset || options.remarkableOptions;
const remarkableOptions = typeof options.remarkableOptions === 'object' ? options.remarkableOptions : null;
const md = new Remarkable(remarkablePreset, remarkableOptions);
// if tables are not explicitly enabled, disable them by default
if (
!remarkableOptions ||
!remarkableOptions.enable ||
!remarkableOptions.enable.block ||
remarkableOptions.enable.block !== 'table' ||
remarkableOptions.enable.block.includes('table') === false
) {
md.block.ruler.disable('table');
}
// disable the specified rules
if (remarkableOptions && remarkableOptions.disable) {
for (let [key, value] of Object.entries(remarkableOptions.disable)) {
md[key].ruler.disable(value);
}
}
// enable the specified rules
if (remarkableOptions && remarkableOptions.enable) {
for (let [key, value] of Object.entries(remarkableOptions.enable)) {
md[key].ruler.enable(value);
}
}
// If users want to define custom remarkable plugins for custom markdown, they can be added here
if (options.remarkablePlugins) {
options.remarkablePlugins.forEach(function (plugin) {
md.use(plugin, {});
});
}
var blocks = []; // blocks will be returned as part of the final draftjs raw object
var entityMap = {}; // entitymap will be returned as part of the final draftjs raw object
var parsedData = md.parse(string, {}); // remarkable js takes markdown and makes it an array of style objects for us to easily parse
var currentListType = null; // Because of how remarkable's data is formatted, we need to cache what kind of list we're currently dealing with
var previousBlockEndingLine = 0;
// Allow user to define custom BlockTypes and Entities if they so wish
const BlockTypes = Object.assign({}, DefaultBlockTypes, options.blockTypes || {});
const BlockEntities = Object.assign({}, DefaultBlockEntities, options.blockEntities || {});
const BlockStyles = Object.assign({}, DefaultBlockStyles, options.blockStyles || {});
parsedData.forEach(function (item) {
// Because of how remarkable's data is formatted, we need to cache what kind of list we're currently dealing with
if (item.type === 'bullet_list_open') {
currentListType = 'unordered_list_item_open';
} else if (item.type === 'ordered_list_open') {
currentListType = 'ordered_list_item_open';
}
var itemType = item.type;
if (itemType === 'list_item_open') {
itemType = currentListType;
}
if (itemType === 'inline') {
// Parse inline content and apply it to the most recently created block level item,
// which is where the inline content will belong.
var {content, blockEntities, blockEntityRanges, blockInlineStyleRanges} = parseInline(item, BlockEntities, BlockStyles);
var blockToModify = blocks[blocks.length - 1];
blockToModify.text = content;
blockToModify.inlineStyleRanges = blockInlineStyleRanges;
blockToModify.entityRanges = blockEntityRanges;
// The entity map is a master object separate from the block so just add any entities created for this block to the master object
Object.assign(entityMap, blockEntities);
} else if ((itemType.indexOf('_open') !== -1 || itemType === 'fence' || itemType === 'hr') && BlockTypes[itemType]) {
var depth = 0;
var block;
if (item.level > 0) {
depth = Math.floor(item.level / 2);
}
// Draftjs only supports 1 level of blocks, hence the item.level === 0 check
// List items will always be at least `level==1` though so we need a separate check for that
// If theres nested block level items deeper than that, we need to make sure we capture this by cloning the topmost block
// otherwise well accidentally overwrite its text. (eg if there's a blockquote with 3 nested paragraphs with inline text, without this check, only the last paragraph would be reflected)
if (item.level === 0 || item.type === 'list_item_open') {
block = Object.assign({
depth: depth
}, BlockTypes[itemType](item));
} else if (item.level > 0 && blocks[blocks.length - 1].text) {
block = Object.assign({}, blocks[blocks.length - 1]);
}
if (block && options.preserveNewlines) {
// Re: previousBlockEndingLine.... omg.
// So remarkable strips out empty newlines and doesn't make any entities to parse to restore them
// the only solution I could find is that there's a 2-value array on each block item called "lines" which is the start and end line of the block element.
// by keeping track of the PREVIOUS block element ending line and the NEXT block element starting line, we can find the difference between the new lines and insert
// an appropriate number of extra paragraphs to re-create those newlines in draftjs.
// This is probably my least favourite thing in this file, but not sure what could be better.
var totalEmptyParagraphsToCreate = item.lines[0] - previousBlockEndingLine;
for (var i = 0; i < totalEmptyParagraphsToCreate; i++) {
blocks.push(DefaultBlockTypes.paragraph_open());
}
}
if (block) {
previousBlockEndingLine = item.lines[1];
blocks.push(block);
}
}
});
// EditorState.createWithContent will error if there's no blocks defined
// Remarkable returns an empty array though. So we have to generate a 'fake'
// empty block in this case. 😑
if (!blocks.length) {
blocks.push(DefaultBlockTypes.paragraph_open());
}
return {
entityMap,
blocks
};
}
export default markdownToDraft;

View File

@@ -366,9 +366,11 @@ export default function compose(state = initialState, action) {
return item;
}));
case STATUS_EDIT:
const hasMarkdown = !!action.status.get('plain_markdown')
return state.withMutations(map => {
map.set('id', action.status.get('id'));
map.set('text', unescapeHTML(expandMentions(action.status)));
map.set('markdown', action.status.get('plain_markdown'));
map.set('in_reply_to', action.status.get('in_reply_to_id'));
map.set('quote_of_id', action.status.get('quote_of_id'));
map.set('privacy', action.status.get('visibility'));
@@ -376,7 +378,7 @@ export default function compose(state = initialState, action) {
map.set('focusDate', new Date());
map.set('caretPosition', null);
map.set('idempotencyKey', uuid());
map.set('rte_controls_visible', false);
map.set('rte_controls_visible', hasMarkdown);
if (action.status.get('spoiler_text').length > 0) {
map.set('spoiler', true);

View File

@@ -13,6 +13,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
attribute :content, unless: :source_requested?
attribute :rich_content, unless: :source_requested?
attribute :plain_markdown, unless: :source_requested?
attribute :text, if: :source_requested?
belongs_to :reblog, serializer: REST::StatusSerializer
@@ -76,6 +77,10 @@ class REST::StatusSerializer < ActiveModel::Serializer
Formatter.instance.format(object, use_markdown: true).strip
end
def plain_markdown
object.markdown
end
def url
TagManager.instance.url_for(object)
end

View File

@@ -7,6 +7,7 @@ class EditStatusService < BaseService
# @param [Status] status Status being edited
# @param [Hash] options
# @option [String] :text Message
# @option [String] :markdown Optional message in markdown
# @option [Boolean] :sensitive
# @option [String] :visibility
# @option [String] :spoiler_text
@@ -20,6 +21,7 @@ class EditStatusService < BaseService
@account = status.account
@options = options
@text = @options[:text] || ''
@markdown = @options[:markdown] if @account.is_pro
return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
@@ -119,6 +121,7 @@ class EditStatusService < BaseService
{
revised_at: Time.now,
text: @text,
markdown: @markdown,
media_attachments: @media || [],
sensitive: (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?,
spoiler_text: @options[:spoiler_text] || '',

View File

@@ -26,7 +26,7 @@ class PostStatusService < BaseService
@account = account
@options = options
@text = @options[:text] || ''
@markdown = @options[:markdown]
@markdown = @options[:markdown] if @account.is_pro
@in_reply_to = @options[:thread]
return idempotency_duplicate if idempotency_given? && idempotency_duplicate?