From 20a3221c4be9aa32e202df94989982b3017e44e7 Mon Sep 17 00:00:00 2001
From: mgabdev <>
Date: Fri, 12 Jun 2020 12:01:54 -0400
Subject: [PATCH 1/5] Rich Text Editor (WIP)
---
app/javascript/gabsocial/actions/compose.js | 13 +--
.../gabsocial/actions/importer/normalizer.js | 65 ++++++++++++--
app/javascript/gabsocial/actions/statuses.js | 1 +
.../components/autosuggest_textbox.js | 15 ++--
.../gabsocial/components/composer.js | 40 +++++----
.../compose/components/compose_form.js | 20 ++---
app/javascript/styles/global.css | 20 ++++-
app/lib/formatter.rb | 88 -------------------
app/serializers/rest/status_serializer.rb | 6 +-
package.json | 2 +
yarn.lock | 13 +++
11 files changed, 144 insertions(+), 139 deletions(-)
diff --git a/app/javascript/gabsocial/actions/compose.js b/app/javascript/gabsocial/actions/compose.js
index f476da49..0aecfcaa 100644
--- a/app/javascript/gabsocial/actions/compose.js
+++ b/app/javascript/gabsocial/actions/compose.js
@@ -263,7 +263,7 @@ export function submitCompose(group, replyToId = null, router, isStandalone) {
if (!me) return;
let status = getState().getIn(['compose', 'text'], '');
- const markdown = getState().getIn(['compose', 'markdown'], '');
+ let markdown = getState().getIn(['compose', 'markdown'], '');
const media = getState().getIn(['compose', 'media_attachments']);
// : hack :
@@ -276,10 +276,13 @@ export function submitCompose(group, replyToId = null, router, isStandalone) {
}
return hasProtocol ? match : `http://${match}`
})
- // markdown = statusMarkdown.replace(urlRegex, (match) =>{
- // const hasProtocol = match.startsWith('https://') || match.startsWith('http://')
- // return hasProtocol ? match : `http://${match}`
- // })
+ markdown = markdown.replace(urlRegex, (match) =>{
+ const hasProtocol = match.startsWith('https://') || match.startsWith('http://')
+ if (!hasProtocol) {
+ if (status.indexOf(`@${match}`) > -1) return match
+ }
+ return hasProtocol ? match : `http://${match}`
+ })
const inReplyToId = getState().getIn(['compose', 'in_reply_to'], null) || replyToId
diff --git a/app/javascript/gabsocial/actions/importer/normalizer.js b/app/javascript/gabsocial/actions/importer/normalizer.js
index eaa4ca17..ac17d6b6 100644
--- a/app/javascript/gabsocial/actions/importer/normalizer.js
+++ b/app/javascript/gabsocial/actions/importer/normalizer.js
@@ -1,9 +1,12 @@
-import escapeTextContentForBrowser from 'escape-html';
-import emojify from '../../components/emoji/emoji';
-import { unescapeHTML } from '../../utils/html';
-import { expandSpoilers } from '../../initial_state';
+import escapeTextContentForBrowser from 'escape-html'
+import { markdownToDraft } from 'markdown-draft-js'
+import { Remarkable } from 'remarkable'
+import * as entities from 'entities'
+import emojify from '../../components/emoji/emoji'
+import { unescapeHTML } from '../../utils/html'
+import { expandSpoilers } from '../../initial_state'
-const domParser = new DOMParser();
+const domParser = new DOMParser()
const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
obj[`:${emoji.shortcode}:`] = emoji;
@@ -63,8 +66,40 @@ export function normalizeStatus(status, normalOldStatus) {
const spoilerText = normalStatus.spoiler_text || '';
const searchContent = [spoilerText, status.content].join('\n\n').replace(/
/g, '\n').replace(/<\/p>
/g, '\n\n'); const emojiMap = makeEmojiMap(normalStatus); - const theContent = !!normalStatus.rich_content ? normalStatus.rich_content : normalStatus.content; + + let theContent + if (!!normalStatus.rich_content) { + theContent = normalStatus.rich_content + // let rawObject = markdownToDraft(theContent, { + // preserveNewlines: true, + // remarkablePreset: 'commonmark', + // remarkableOptions: { + // enable: { + // inline: ['del', 'ins'], + // } + // } + // }); + const md = new Remarkable({ + html: false, + breaks: true, + }) + let html = md.render(theContent) + html = entities.decodeHTML(html) + + theContent = html + + console.log("html:", html) + console.log("theContent:", theContent) + console.log("status:", status) + console.log("normalStatus:", normalStatus) + // console.log("rawObject:", rawObject) + } else { + theContent = normalStatus.content + } + // let theContent = !!normalStatus.rich_content ? normalStatus.rich_content : normalStatus.content; + + normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; normalStatus.contentHtml = emojify(theContent, emojiMap, false, true); normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); @@ -86,3 +121,21 @@ export function normalizePoll(poll) { return normalPoll; } + + +//
#test @bob #nice https://bob.com http://techcrunch.com strike it
https://twitter.com
@bobitalic
+//jonincode
+
+// # attention!
+// #test @bob #nice https://bob.com http://techcrunch.com ~~strike it~~
+
+// ~~https://twitter.com~~
+
+// _@bobitalic_
+
+// ```
+// jonincode
+// ```
\ No newline at end of file
diff --git a/app/javascript/gabsocial/actions/statuses.js b/app/javascript/gabsocial/actions/statuses.js
index d22b6df1..60af0990 100644
--- a/app/javascript/gabsocial/actions/statuses.js
+++ b/app/javascript/gabsocial/actions/statuses.js
@@ -109,6 +109,7 @@ export function fetchStatus(id) {
}).then(() => {
dispatch(fetchStatusSuccess(skipLoading));
}, () => api(getState).get(`/api/v1/statuses/${id}`).then(response => {
+ console.log("response.data:", response.data)
dispatch(importFetchedStatus(response.data));
dispatch(fetchStatusSuccess(skipLoading));
})).catch(error => {
diff --git a/app/javascript/gabsocial/components/autosuggest_textbox.js b/app/javascript/gabsocial/components/autosuggest_textbox.js
index 80a92e99..40e945a6 100644
--- a/app/javascript/gabsocial/components/autosuggest_textbox.js
+++ b/app/javascript/gabsocial/components/autosuggest_textbox.js
@@ -53,11 +53,12 @@ export default class AutosuggestTextbox extends ImmutablePureComponent {
tokenStart: 0,
}
- onChange = (e, value, selectionStart, markdown) => {
+ onChange = (e, value, markdown, selectionStart) => {
if (!isObject(e)) {
e = {
target: {
value,
+ markdown,
selectionStart,
},
}
@@ -65,8 +66,6 @@ export default class AutosuggestTextbox extends ImmutablePureComponent {
const [ tokenStart, token ] = textAtCursorMatchesToken(e.target.value, e.target.selectionStart, this.props.searchTokens);
- // console.log('onChange', e.target.value, e.target, this.textbox, tokenStart, token)
-
if (token !== null && this.state.lastToken !== token) {
this.setState({ lastToken: token, selectedSuggestion: 0, tokenStart });
this.props.onSuggestionsFetchRequested(token);
@@ -75,7 +74,7 @@ export default class AutosuggestTextbox extends ImmutablePureComponent {
this.props.onSuggestionsClearRequested();
}
- this.props.onChange(e, markdown);
+ this.props.onChange(e);
}
onKeyDown = (e) => {
@@ -259,7 +258,7 @@ export default class AutosuggestTextbox extends ImmutablePureComponent {
return (
#{content}
"
- end
- end
-
- def convert_codeblock(html)
- html.gsub(/```\w*(.*(\r\n|\r|\n))+```/) do |code|
- lang = code.match(/```\w+/)[0].gsub(/`/, '')
- content = code.gsub(/```\w+/, '```').gsub(/`/, '')
- "#{content}
"
- end
- end
-
- def convert_links(html)
- html.gsub(/\[(\w|\s)+\]\((\w|\W)+\)/) do |anchor|
- link_text = anchor.match(/\[(\w|\s)+\]/)[0].gsub(/[\[\]]/, '')
- href = anchor.match(/\((\w|\W)+\)/)[0].gsub(/\(|\)/, '')
- "#{link_text}"
- end
- end
-
- def convert_lists(html)
- html.gsub(/(\-.+(\r|\n|\r\n))+/) do |list|
- items = "/g, '\n\n'); const emojiMap = makeEmojiMap(normalStatus); - - let theContent - if (!!normalStatus.rich_content) { - theContent = normalStatus.rich_content - // let rawObject = markdownToDraft(theContent, { - // preserveNewlines: true, - // remarkablePreset: 'commonmark', - // remarkableOptions: { - // enable: { - // inline: ['del', 'ins'], - // } - // } - // }); + const theContent = !!normalStatus.rich_content ? normalStatus.rich_content : normalStatus.content; - const md = new Remarkable({ - html: false, - breaks: true, - }) - let html = md.render(theContent) - html = entities.decodeHTML(html) - - theContent = html - - console.log("html:", html) - console.log("theContent:", theContent) - console.log("status:", status) - console.log("normalStatus:", normalStatus) - // console.log("rawObject:", rawObject) - } else { - theContent = normalStatus.content - } - // let theContent = !!normalStatus.rich_content ? normalStatus.rich_content : normalStatus.content; - - normalStatus.search_index = domParser.parseFromString(searchContent, 'text/html').documentElement.textContent; normalStatus.contentHtml = emojify(theContent, emojiMap, false, true); normalStatus.spoilerHtml = emojify(escapeTextContentForBrowser(spoilerText), emojiMap); diff --git a/app/javascript/gabsocial/components/composer.js b/app/javascript/gabsocial/components/composer.js index 1a0d9700..7be61d27 100644 --- a/app/javascript/gabsocial/components/composer.js +++ b/app/javascript/gabsocial/components/composer.js @@ -4,10 +4,8 @@ import { CompositeDecorator, RichUtils, convertToRaw, - convertFromRaw, - ContentState, } from 'draft-js' -import { draftToMarkdown } from 'markdown-draft-js' +import draftToMarkdown from '../features/ui/util/draft-to-markdown' import { urlRegex } from '../features/ui/util/url_regex' import classNames from 'classnames/bind' import RichTextEditorBar from './rich_text_editor_bar' @@ -134,7 +132,8 @@ class Composer extends PureComponent { const rawObject = convertToRaw(content); const markdownString = draftToMarkdown(rawObject, { - preserveNewlines: true, + escapeMarkdownCharacters: false, + preserveNewlines: false, remarkablePreset: 'commonmark', remarkableOptions: { disable: { @@ -147,6 +146,7 @@ class Composer extends PureComponent { }); console.log("text:", markdownString) + // console.log("html:", html) this.props.onChange(null, text, markdownString, selectionStart) } @@ -154,11 +154,12 @@ class Composer extends PureComponent { // **bold** // *italic* // __underline__ - // ~strikethrough~ - // # title + // ~~strike~~ + // # header // > quote - // `code` - // ```code``` + // ``` + // code + // ``` focus = () => { this.textbox.editor.focus() diff --git a/app/javascript/gabsocial/components/rich_text_editor_bar.js b/app/javascript/gabsocial/components/rich_text_editor_bar.js index f8b8d9dd..f6b220e9 100644 --- a/app/javascript/gabsocial/components/rich_text_editor_bar.js +++ b/app/javascript/gabsocial/components/rich_text_editor_bar.js @@ -1,5 +1,4 @@ import { RichUtils } from 'draft-js' -import { defineMessages, injectIntl } from 'react-intl' import classNames from 'classnames/bind' import { me } from '../initial_state' import { makeGetAccount } from '../selectors' @@ -70,10 +69,6 @@ const RTE_ITEMS = [ }, ] -const messages = defineMessages({ - follow: { id: 'follow', defaultMessage: 'Follow' }, -}) - const mapStateToProps = (state) => { const getAccount = makeGetAccount() const account = getAccount(state, me) @@ -86,13 +81,11 @@ const mapStateToProps = (state) => { } export default -@injectIntl @connect(mapStateToProps) class RichTextEditorBar extends PureComponent { static propTypes = { editorState: PropTypes.object.isRequired, - intl: PropTypes.object.isRequired, isPro: PropTypes.bool.isRequired, rteControlsVisible: PropTypes.bool.isRequired, onChange: PropTypes.func.isRequired, @@ -127,7 +120,7 @@ class RichTextEditorBar extends PureComponent { /> )) } - + />*/} ) } diff --git a/app/javascript/gabsocial/components/status_list.js b/app/javascript/gabsocial/components/status_list.js index 93c65a0a..55801f64 100644 --- a/app/javascript/gabsocial/components/status_list.js +++ b/app/javascript/gabsocial/components/status_list.js @@ -3,7 +3,7 @@ import { Map as ImmutableMap, List as ImmutableList } from 'immutable'; import ImmutablePropTypes from 'react-immutable-proptypes'; import ImmutablePureComponent from 'react-immutable-pure-component'; import { createSelector } from 'reselect'; -import sample from 'lodash.sample'; +// import sample from 'lodash.sample'; import debounce from 'lodash.debounce' import { me, promotions } from '../initial_state'; import { dequeueTimeline } from '../actions/timelines'; @@ -44,7 +44,7 @@ const mapStateToProps = (state, { timelineId }) => { if (!timelineId) return {} const getStatusIds = makeGetStatusIds(); - const promotion = promotions.length > 0 && sample(promotions.filter(p => p.timeline_id === timelineId)); + const promotion = false//promotions.length > 0 && sample(promotions.filter(p => p.timeline_id === timelineId)); const statusIds = getStatusIds(state, { type: timelineId.substring(0, 5) === 'group' ? 'group' : timelineId, diff --git a/app/javascript/gabsocial/features/ui/util/draft-to-markdown.js b/app/javascript/gabsocial/features/ui/util/draft-to-markdown.js new file mode 100644 index 00000000..ca95bcc2 --- /dev/null +++ b/app/javascript/gabsocial/features/ui/util/draft-to-markdown.js @@ -0,0 +1,456 @@ +// https://raw.githubusercontent.com/Rosey/markdown-draft-js/main/src/draft-to-markdown.js +const TRAILING_WHITESPACE = /[ \u0020\t\n]*$/; + +// This escapes some markdown but there's a few cases that are TODO - +// - List items +// - Back tics (see https://github.com/Rosey/markdown-draft-js/issues/52#issuecomment-388458017) +// - Complex markdown, like links or images. Not sure it's even worth it, because if you're typing +// that into draft chances are you know its markdown and maybe expect it convert? :/ +const MARKDOWN_STYLE_CHARACTERS = ['*', '_', '~', '`']; +const MARKDOWN_STYLE_CHARACTER_REGXP = /(\*|_|~|\\|`)/g; + +// I hate this a bit, being outside of the function’s scope +// but can’t think of a better way to keep track of how many ordered list +// items were are on, as draft doesn’t explicitly tell us in the raw object 😢. +// This is a hash that will be assigned values based on depth, so like +// orderedListNumber[0] = 1 would mean that ordered list at depth 0 is on number 1. +// orderedListNumber[0] = 2 would mean that ordered list at depth 0 is on number 2. +// This is so we have the right number of numbers when doing a list, eg +// 1. Item One +// 2. Item two +// 3. Item three +// And so on. +var orderedListNumber = {}, + previousOrderedListDepth = 0; + +// A map of draftjs block types -> markdown open and close characters +// Both the open and close methods must exist, even if they simply return an empty string. +// They should always return a string. +const StyleItems = { + // BLOCK LEVEL + 'unordered-list-item': { + open: function () { + return '- '; + }, + + close: function () { + return ''; + } + }, + + 'ordered-list-item': { + open: function (block, number = 1) { + return `${number}. `; + }, + + close: function () { + return ''; + } + }, + + 'blockquote': { + open: function () { + return '> '; + }, + + close: function () { + return ''; + } + }, + + 'header-one': { + open: function () { + return '# '; + }, + + close: function () { + return ''; + } + }, + + 'code-block': { + open: function (block) { + return '```' + (block.data.language || '') + '\n'; + }, + + close: function () { + return '\n```'; + } + }, + + // INLINE LEVEL + 'BOLD': { + open: function () { + return '**'; + }, + + close: function () { + return '**'; + } + }, + + 'ITALIC': { + open: function () { + return '*'; + }, + + close: function () { + return '*'; + } + }, + + 'UNDERLINE': { + open: function () { + return '_'; + }, + + close: function () { + return '_'; + } + }, + + 'STRIKETHROUGH': { + open: function () { + return '~~'; + }, + + close: function () { + return '~~'; + } + }, + + 'CODE': { + open: function () { + return '`'; + }, + + close: function () { + return '`'; + } + } +}; + +// A map of draftjs entity types -> markdown open and close characters +// entities are different from block types because they have additional data attached to them. +// an entity object is passed in to both open and close, in case it's needed for string generation. +// +// Both the open and close methods must exist, even if they simply return an empty string. +// They should always return a string. +const EntityItems = { + // +} + +// Bit of a hack - we normally want a double newline after a block, +// but for list items we just want one (unless it's the _last_ list item in a group.) +const SingleNewlineAfterBlock = [ + 'unordered-list-item', + 'ordered-list-item' +]; + +function isEmptyBlock(block) { + return block.text.length === 0 && block.entityRanges.length === 0 && Object.keys(block.data || {}).length === 0; +} + +/** + * Generate markdown for a single block javascript object + * DraftJS raw object contains an array of blocks, which is the main "structure" + * of the text. Each block = a new line. + * + * @param {Object} block - block to generate markdown for + * @param {Number} index - index of the block in the blocks array + * @param {Object} rawDraftObject - entire raw draft object (needed for accessing the entityMap) + * @param {Object} options - additional options passed in by the user calling this method. + * + * @return {String} markdown string +**/ +function renderBlock(block, index, rawDraftObject, options) { + var openInlineStyles = [], + markdownToAdd = []; + var markdownString = '', + customStyleItems = options.styleItems || {}, + customEntityItems = options.entityItems || {}, + escapeMarkdownCharacters = options.hasOwnProperty('escapeMarkdownCharacters') ? options.escapeMarkdownCharacters : true; + + var type = block.type; + + var markdownStyleCharactersToEscape = []; + + // draft-js emits empty blocks that have type set… don’t style them unless the user wants to preserve new lines + // (if newlines are preserved each empty line should be "styled" eg in case of blockquote we want to see a blockquote.) + // but if newlines aren’t preserved then we'd end up having double or triple or etc markdown characters, which is a bug. + if (isEmptyBlock(block) && !options.preserveNewlines) { + type = 'unstyled'; + } + + // Render main block wrapping element + if (customStyleItems[type] || StyleItems[type]) { + if (type === 'unordered-list-item' || type === 'ordered-list-item') { + markdownString += ' '.repeat(block.depth * 4); + } + + if (type === 'ordered-list-item') { + orderedListNumber[block.depth] = orderedListNumber[block.depth] || 1; + markdownString += (customStyleItems[type] || StyleItems[type]).open(block, orderedListNumber[block.depth]); + orderedListNumber[block.depth]++; + + // Have to reset the number for orderedListNumber if we are breaking out of a list so that if + // there's another nested list at the same level further down, it starts at 1 again. + // COMPLICATED 😭 + if (previousOrderedListDepth > block.depth) { + orderedListNumber[previousOrderedListDepth] = 1; + } + + previousOrderedListDepth = block.depth; + } else { + orderedListNumber = {}; + markdownString += (customStyleItems[type] || StyleItems[type]).open(block); + } + } + + // Render text within content, along with any inline styles/entities + Array.from(block.text).some(function (character, characterIndex) { + // Close any entity tags that need closing + block.entityRanges.forEach(function (range, rangeIndex) { + if (range.offset + range.length === characterIndex) { + var entity = rawDraftObject.entityMap[range.key]; + if (customEntityItems[entity.type] || EntityItems[entity.type]) { + markdownString += (customEntityItems[entity.type] || EntityItems[entity.type]).close(entity); + } + } + }); + + // Close any inline tags that need closing + openInlineStyles.forEach(function (style, styleIndex) { + if (style.offset + style.length === characterIndex) { + if ((customStyleItems[style.style] || StyleItems[style.style])) { + var styleIndex = openInlineStyles.indexOf(style); + // Handle nested case - close any open inline styles before closing the parent + if (styleIndex > -1 && styleIndex !== openInlineStyles.length - 1) { + for (var i = openInlineStyles.length - 1; i !== styleIndex; i--) { + var styleItem = (customStyleItems[openInlineStyles[i].style] || StyleItems[openInlineStyles[i].style]); + if (styleItem) { + var trailingWhitespace = TRAILING_WHITESPACE.exec(markdownString); + markdownString = markdownString.slice(0, markdownString.length - trailingWhitespace[0].length); + markdownString += styleItem.close(); + markdownString += trailingWhitespace[0]; + } + } + } + + // Close the actual inline style being closed + // Have to trim whitespace first and then re-add after because markdown can't handle leading/trailing whitespace + var trailingWhitespace = TRAILING_WHITESPACE.exec(markdownString); + markdownString = markdownString.slice(0, markdownString.length - trailingWhitespace[0].length); + + markdownString += (customStyleItems[style.style] || StyleItems[style.style]).close(); + markdownString += trailingWhitespace[0]; + + // Handle nested case - reopen any inline styles after closing the parent + if (styleIndex > -1 && styleIndex !== openInlineStyles.length - 1) { + for (var i = openInlineStyles.length - 1; i !== styleIndex; i--) { + var styleItem = (customStyleItems[openInlineStyles[i].style] || StyleItems[openInlineStyles[i].style]); + if (styleItem && openInlineStyles[i].offset + openInlineStyles[i].length > characterIndex) { + markdownString += styleItem.open(); + } else { + openInlineStyles.splice(i, 1); + } + } + } + + openInlineStyles.splice(styleIndex, 1); + } + } + }); + + // Open any inline tags that need opening + block.inlineStyleRanges.forEach(function (style, styleIndex) { + if (style.offset === characterIndex) { + if ((customStyleItems[style.style] || StyleItems[style.style])) { + var styleToAdd = (customStyleItems[style.style] || StyleItems[style.style]).open(); + markdownToAdd.push({ + type: 'style', + style: style, + value: styleToAdd + }); + } + } + }); + + // Open any entity tags that need opening + block.entityRanges.forEach(function (range, rangeIndex) { + if (range.offset === characterIndex) { + var entity = rawDraftObject.entityMap[range.key]; + if (customEntityItems[entity.type] || EntityItems[entity.type]) { + var entityToAdd = (customEntityItems[entity.type] || EntityItems[entity.type]).open(entity); + markdownToAdd.push({ + type: 'entity', + value: entityToAdd + }); + } + } + }); + + // These are all the opening entity and style types being added to the markdown string for this loop + // we store in an array and add here because if the character is WS character, we want to hang onto it and not apply it until the next non-whitespace + // character before adding the markdown, since markdown doesn’t play nice with leading whitespace (eg '** bold**' is no good, whereas ' **bold**' is good.) + if (character !== ' ' && markdownToAdd.length) { + markdownString += markdownToAdd.map(function (item) { + return item.value; + }).join(''); + + markdownToAdd.forEach(function (item) { + if (item.type === 'style') { + // We hang on to this because we may need to close it early and then re-open if there are nested styles being opened and closed. + openInlineStyles.push(item.style); + } + }); + + markdownToAdd = []; + } + + if (block.type !== 'code-block' && escapeMarkdownCharacters) { + let insideInlineCodeStyle = openInlineStyles.find((style) => style.style === 'CODE'); + + if (insideInlineCodeStyle) { + // Todo - The syntax to escape backtics when inside backtic code already is to use MORE backtics wrapping. + // So we need to see how many backtics in a row we have and then when converting to markdown, use that # + 1 + + // EG ``Test ` Hllo `` + // OR ```Test `` Hello``` + // OR ````Test ``` Hello ```` + // Similar work has to be done for codeblocks. + } else { + // Special escape logic for blockquotes and heading characters + if (characterIndex === 0 && character === '#' && block.text[1] && block.text[1] === ' ') { + character = character.replace('#', '\\#'); + } else if (characterIndex === 0 && character === '>') { + character = character.replace('>', '\\>'); + } + + // Escaping inline markdown characters + // 🧹 If someone can think of a more elegant solution, I would love that. + // orginally this was just a little char replace using a simple regular expression, but there’s lots of cases where + // a markdown character does not actually get converted to markdown, like this case: http://google.com/i_am_a_link + // so this code now tries to be smart and keeps track of potential “opening” characters as well as potential “closing” + // characters, and only escapes if both opening and closing exist, and they have the correct whitepace-before-open, whitespace-or-end-of-string-after-close pattern + if (MARKDOWN_STYLE_CHARACTERS.includes(character)) { + let openingStyle = markdownStyleCharactersToEscape.find(function (item) { + return item.character === character; + }); + + if (!openingStyle && block.text[characterIndex - 1] === ' ' && block.text[characterIndex + 1] !== ' ') { + markdownStyleCharactersToEscape.push({ + character: character, + index: characterIndex, + markdownStringIndexStart: markdownString.length + character.length - 1, + markdownStringIndexEnd: markdownString.length + character.length + }); + } else if (openingStyle && block.text[characterIndex - 1] === character && characterIndex === openingStyle.index + 1) { + openingStyle.markdownStringIndexEnd += 1; + } else if (openingStyle) { + let openingStyleLength = openingStyle.markdownStringIndexEnd - openingStyle.markdownStringIndexStart; + let escapeCharacter = false; + let popOpeningStyle = false; + if (openingStyleLength === 1 && (block.text[characterIndex + 1] === ' ' || !block.text[characterIndex + 1])) { + popOpeningStyle = true; + escapeCharacter = true; + } + + if (openingStyleLength === 2 && block.text[characterIndex + 1] === character) { + escapeCharacter = true; + } + + if (openingStyleLength === 2 && block.text[characterIndex - 1] === character && (block.text[characterIndex + 1] === ' ' || !block.text[characterIndex + 1])) { + popOpeningStyle = true; + escapeCharacter = true; + } + + if (popOpeningStyle) { + markdownStyleCharactersToEscape.splice(markdownStyleCharactersToEscape.indexOf(openingStyle), 1); + let replacementString = markdownString.slice(openingStyle.markdownStringIndexStart, openingStyle.markdownStringIndexEnd); + replacementString = replacementString.replace(MARKDOWN_STYLE_CHARACTER_REGXP, '\\$1'); + markdownString = (markdownString.slice(0, openingStyle.markdownStringIndexStart) + replacementString + markdownString.slice(openingStyle.markdownStringIndexEnd)); + } + + if (escapeCharacter) { + character = `\\${character}`; + } + } + } + } + } + + if (character === '\n' && type === 'blockquote') { + markdownString += '\n> '; + } else { + markdownString += character; + } + }); + + // Close any remaining entity tags + block.entityRanges.forEach(function (range, rangeIndex) { + if (range.offset + range.length === Array.from(block.text).length) { + var entity = rawDraftObject.entityMap[range.key]; + if (customEntityItems[entity.type] || EntityItems[entity.type]) { + markdownString += (customEntityItems[entity.type] || EntityItems[entity.type]).close(entity); + } + } + }); + + // Close any remaining inline tags (if an inline tag ends at the very last char, we won't catch it inside the loop) + openInlineStyles.reverse().forEach(function (style) { + var trailingWhitespace = TRAILING_WHITESPACE.exec(markdownString); + markdownString = markdownString.slice(0, markdownString.length - trailingWhitespace[0].length); + markdownString += (customStyleItems[style.style] || StyleItems[style.style]).close(); + markdownString += trailingWhitespace[0]; + }); + + // Close block level item + if (customStyleItems[type] || StyleItems[type]) { + markdownString += (customStyleItems[type] || StyleItems[type]).close(block); + } + + // Determine how many newlines to add - generally we want 2, but for list items we just want one when they are succeeded by another list item. + if (SingleNewlineAfterBlock.indexOf(type) !== -1 && rawDraftObject.blocks[index + 1] && SingleNewlineAfterBlock.indexOf(rawDraftObject.blocks[index + 1].type) !== -1) { + markdownString += '\n'; + } else if (rawDraftObject.blocks[index + 1]) { + if (rawDraftObject.blocks[index].text) { + if (SingleNewlineAfterBlock.indexOf(type) !== -1 + && SingleNewlineAfterBlock.indexOf(rawDraftObject.blocks[index + 1].type) === -1) { + markdownString += '\n\n'; + } else if (!options.preserveNewlines) { + // 2 newlines if not preserving + markdownString += '\n\n'; + } else { + markdownString += '\n'; + } + } else if (options.preserveNewlines) { + markdownString += '\n'; + } + } + + return markdownString; +} + +/** + * Generate markdown for a raw draftjs object + * DraftJS raw object contains an array of blocks, which is the main "structure" + * of the text. Each block = a new line. + * + * @param {Object} rawDraftObject - draftjs object to generate markdown for + * @param {Object} options - optional additional data, see readme for what options can be passed in. + * + * @return {String} markdown string +**/ +function draftToMarkdown(rawDraftObject, options) { + options = options || {}; + var markdownString = ''; + rawDraftObject.blocks.forEach(function (block, index) { + markdownString += renderBlock(block, index, rawDraftObject, options); + }); + + orderedListNumber = {}; // See variable definitions at the top of the page to see why we have to do this sad hack. + return markdownString; +} + +export default draftToMarkdown; \ No newline at end of file diff --git a/app/javascript/styles/global.css b/app/javascript/styles/global.css index a7e7d6d4..49879372 100644 --- a/app/javascript/styles/global.css +++ b/app/javascript/styles/global.css @@ -169,6 +169,10 @@ pre { margin-bottom: 0.5rem; } +.statusContent h1 * { + font-size: var(--fs_xl) !important; +} + .statusContent ul, .statusContent ol { padding-left: 40px; @@ -183,6 +187,12 @@ pre { /* list-style-type: disc; */ } +.statusContent code { + background-color: rgba(0,0,0,.05); + padding-left: 0.5em; + padding-right: 0.5em; +} + .dangerousContent, .dangerousContent * { margin-top: 0; diff --git a/app/lib/formatter.rb b/app/lib/formatter.rb index 049924e0..1e46befc 100644 --- a/app/lib/formatter.rb +++ b/app/lib/formatter.rb @@ -3,6 +3,73 @@ require 'singleton' require_relative './sanitize_config' +class HTMLRenderer < Redcarpet::Render::HTML + def block_code(code, language) + "
#{encode(code).gsub("\n", "
")}
"
+ end
+
+ def block_quote(quote)
+ "#{quote}" + end + + def codespan(code) + "
#{code}
"
+ end
+
+ def double_emphasis(text)
+ "#{text}"
+ end
+
+ def emphasis(text)
+ "#{text}"
+ end
+
+ def header(text, header_level)
+ "