Merge branch 'feature/rich_text_editor' of https://code.gab.com/gab/social/gab-social into develop
This commit is contained in:
commit
f7e0528d3b
2
Gemfile
2
Gemfile
|
@ -94,6 +94,8 @@ gem 'json-ld', '~> 3.0'
|
||||||
gem 'json-ld-preloaded', '~> 3.0'
|
gem 'json-ld-preloaded', '~> 3.0'
|
||||||
gem 'rdf-normalize', '~> 0.3'
|
gem 'rdf-normalize', '~> 0.3'
|
||||||
|
|
||||||
|
gem 'redcarpet', '~> 3.4'
|
||||||
|
|
||||||
group :development, :test do
|
group :development, :test do
|
||||||
gem 'fabrication', '~> 2.20'
|
gem 'fabrication', '~> 2.20'
|
||||||
gem 'fuubar', '~> 2.3'
|
gem 'fuubar', '~> 2.3'
|
||||||
|
|
|
@ -479,6 +479,7 @@ GEM
|
||||||
link_header (~> 0.0, >= 0.0.8)
|
link_header (~> 0.0, >= 0.0.8)
|
||||||
rdf-normalize (0.3.3)
|
rdf-normalize (0.3.3)
|
||||||
rdf (>= 2.2, < 4.0)
|
rdf (>= 2.2, < 4.0)
|
||||||
|
redcarpet (3.4.0)
|
||||||
redis (4.1.2)
|
redis (4.1.2)
|
||||||
redis-actionpack (5.0.2)
|
redis-actionpack (5.0.2)
|
||||||
actionpack (>= 4.0, < 6)
|
actionpack (>= 4.0, < 6)
|
||||||
|
@ -740,6 +741,7 @@ DEPENDENCIES
|
||||||
rails-i18n (~> 5.1)
|
rails-i18n (~> 5.1)
|
||||||
rails-settings-cached (~> 0.6)
|
rails-settings-cached (~> 0.6)
|
||||||
rdf-normalize (~> 0.3)
|
rdf-normalize (~> 0.3)
|
||||||
|
redcarpet (~> 3.4)
|
||||||
redis (~> 4.1)
|
redis (~> 4.1)
|
||||||
redis-namespace (~> 1.5)
|
redis-namespace (~> 1.5)
|
||||||
redis-rails (~> 5.0)
|
redis-rails (~> 5.0)
|
||||||
|
|
|
@ -52,9 +52,10 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
end
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
|
markdown = status_params[:markdown] unless status_params[:markdown] === status_params[:status]
|
||||||
@status = PostStatusService.new.call(current_user.account,
|
@status = PostStatusService.new.call(current_user.account,
|
||||||
text: status_params[:status],
|
text: status_params[:status],
|
||||||
markdown: status_params[:markdown],
|
markdown: markdown,
|
||||||
thread: status_params[:in_reply_to_id].blank? ? nil : Status.find(status_params[:in_reply_to_id]),
|
thread: status_params[:in_reply_to_id].blank? ? nil : Status.find(status_params[:in_reply_to_id]),
|
||||||
media_ids: status_params[:media_ids],
|
media_ids: status_params[:media_ids],
|
||||||
sensitive: status_params[:sensitive],
|
sensitive: status_params[:sensitive],
|
||||||
|
@ -72,9 +73,10 @@ class Api::V1::StatusesController < Api::BaseController
|
||||||
|
|
||||||
def update
|
def update
|
||||||
authorize @status, :update?
|
authorize @status, :update?
|
||||||
|
markdown = status_params[:markdown] unless status_params[:markdown] === status_params[:status]
|
||||||
@status = EditStatusService.new.call(@status,
|
@status = EditStatusService.new.call(@status,
|
||||||
text: status_params[:status],
|
text: status_params[:status],
|
||||||
|
markdown: markdown,
|
||||||
media_ids: status_params[:media_ids],
|
media_ids: status_params[:media_ids],
|
||||||
sensitive: status_params[:sensitive],
|
sensitive: status_params[:sensitive],
|
||||||
spoiler_text: status_params[:spoiler_text],
|
spoiler_text: status_params[:spoiler_text],
|
||||||
|
|
|
@ -263,12 +263,12 @@ export function submitCompose(group, replyToId = null, router, isStandalone) {
|
||||||
if (!me) return;
|
if (!me) return;
|
||||||
|
|
||||||
let status = getState().getIn(['compose', 'text'], '');
|
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']);
|
const media = getState().getIn(['compose', 'media_attachments']);
|
||||||
|
|
||||||
// : hack :
|
// : hack :
|
||||||
//Prepend http:// to urls in status that don't have protocol
|
//Prepend http:// to urls in status that don't have protocol
|
||||||
status = status.replace(urlRegex, (match, a, b, c) =>{
|
status = `${status}`.replace(urlRegex, (match, a, b, c) =>{
|
||||||
const hasProtocol = match.startsWith('https://') || match.startsWith('http://')
|
const hasProtocol = match.startsWith('https://') || match.startsWith('http://')
|
||||||
//Make sure not a remote mention like @someone@somewhere.com
|
//Make sure not a remote mention like @someone@somewhere.com
|
||||||
if (!hasProtocol) {
|
if (!hasProtocol) {
|
||||||
|
@ -276,15 +276,20 @@ export function submitCompose(group, replyToId = null, router, isStandalone) {
|
||||||
}
|
}
|
||||||
return hasProtocol ? match : `http://${match}`
|
return hasProtocol ? match : `http://${match}`
|
||||||
})
|
})
|
||||||
// markdown = statusMarkdown.replace(urlRegex, (match) =>{
|
markdown = !!markdown ? markdown.replace(urlRegex, (match) =>{
|
||||||
// const hasProtocol = match.startsWith('https://') || match.startsWith('http://')
|
const hasProtocol = match.startsWith('https://') || match.startsWith('http://')
|
||||||
// return hasProtocol ? match : `http://${match}`
|
if (!hasProtocol) {
|
||||||
// })
|
if (status.indexOf(`@${match}`) > -1) return match
|
||||||
|
}
|
||||||
|
return hasProtocol ? match : `http://${match}`
|
||||||
|
}) : undefined
|
||||||
|
|
||||||
|
if (status === markdown) {
|
||||||
|
markdown = undefined
|
||||||
|
}
|
||||||
|
|
||||||
const inReplyToId = getState().getIn(['compose', 'in_reply_to'], null) || replyToId
|
const inReplyToId = getState().getIn(['compose', 'in_reply_to'], null) || replyToId
|
||||||
|
|
||||||
// console.log("markdown:", markdown)
|
|
||||||
|
|
||||||
dispatch(submitComposeRequest());
|
dispatch(submitComposeRequest());
|
||||||
dispatch(closeModal());
|
dispatch(closeModal());
|
||||||
|
|
||||||
|
@ -706,9 +711,9 @@ export function changeScheduledAt(date) {
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export function changeRichTextEditorControlsVisibility(status) {
|
export function changeRichTextEditorControlsVisibility(open) {
|
||||||
return {
|
return {
|
||||||
type: COMPOSE_RICH_TEXT_EDITOR_CONTROLS_VISIBILITY,
|
type: COMPOSE_RICH_TEXT_EDITOR_CONTROLS_VISIBILITY,
|
||||||
status: status,
|
open,
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,9 +1,9 @@
|
||||||
import escapeTextContentForBrowser from 'escape-html';
|
import escapeTextContentForBrowser from 'escape-html'
|
||||||
import emojify from '../../components/emoji/emoji';
|
import emojify from '../../components/emoji/emoji'
|
||||||
import { unescapeHTML } from '../../utils/html';
|
import { unescapeHTML } from '../../utils/html'
|
||||||
import { expandSpoilers } from '../../initial_state';
|
import { expandSpoilers } from '../../initial_state'
|
||||||
|
|
||||||
const domParser = new DOMParser();
|
const domParser = new DOMParser()
|
||||||
|
|
||||||
const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
|
const makeEmojiMap = record => record.emojis.reduce((obj, emoji) => {
|
||||||
obj[`:${emoji.shortcode}:`] = emoji;
|
obj[`:${emoji.shortcode}:`] = emoji;
|
||||||
|
@ -86,3 +86,21 @@ export function normalizePoll(poll) {
|
||||||
|
|
||||||
return normalPoll;
|
return normalPoll;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// <p><h1>attention!</h1></p>
|
||||||
|
// <p>#test @bob #nice https://bob.com http://techcrunch.com <del>strike it</del></p>
|
||||||
|
// <p><del>https://twitter.com</del></p>
|
||||||
|
// <p><em>@bobitalic</em></p>
|
||||||
|
// <p><pre><code>jonincode</code></pre></p>
|
||||||
|
|
||||||
|
// # attention!
|
||||||
|
// #test @bob #nice https://bob.com http://techcrunch.com ~~strike it~~
|
||||||
|
|
||||||
|
// ~~https://twitter.com~~
|
||||||
|
|
||||||
|
// _@bobitalic_
|
||||||
|
|
||||||
|
// ```
|
||||||
|
// jonincode
|
||||||
|
// ```
|
|
@ -15,6 +15,7 @@ export default class AutosuggestTextbox extends ImmutablePureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
value: PropTypes.string,
|
value: PropTypes.string,
|
||||||
|
valueMarkdown: PropTypes.string,
|
||||||
suggestions: ImmutablePropTypes.list,
|
suggestions: ImmutablePropTypes.list,
|
||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
placeholder: PropTypes.string,
|
placeholder: PropTypes.string,
|
||||||
|
@ -45,11 +46,12 @@ export default class AutosuggestTextbox extends ImmutablePureComponent {
|
||||||
tokenStart: 0,
|
tokenStart: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange = (e, value, selectionStart, markdown) => {
|
onChange = (e, value, markdown, selectionStart) => {
|
||||||
if (!isObject(e)) {
|
if (!isObject(e)) {
|
||||||
e = {
|
e = {
|
||||||
target: {
|
target: {
|
||||||
value,
|
value,
|
||||||
|
markdown,
|
||||||
selectionStart,
|
selectionStart,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
@ -65,7 +67,7 @@ export default class AutosuggestTextbox extends ImmutablePureComponent {
|
||||||
this.props.onSuggestionsClearRequested();
|
this.props.onSuggestionsClearRequested();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.props.onChange(e, markdown);
|
this.props.onChange(e);
|
||||||
}
|
}
|
||||||
|
|
||||||
onKeyDown = (e) => {
|
onKeyDown = (e) => {
|
||||||
|
@ -191,7 +193,7 @@ export default class AutosuggestTextbox extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
setTextbox = (c) => {
|
setTextbox = (c) => {
|
||||||
this.textbox = c;
|
this.textbox = c
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
|
@ -203,6 +205,7 @@ export default class AutosuggestTextbox extends ImmutablePureComponent {
|
||||||
placeholder,
|
placeholder,
|
||||||
onKeyUp,
|
onKeyUp,
|
||||||
children,
|
children,
|
||||||
|
valueMarkdown,
|
||||||
id,
|
id,
|
||||||
} = this.props
|
} = this.props
|
||||||
|
|
||||||
|
@ -246,29 +249,14 @@ export default class AutosuggestTextbox extends ImmutablePureComponent {
|
||||||
<label htmlFor={id} className={_s.visiblyHidden}>
|
<label htmlFor={id} className={_s.visiblyHidden}>
|
||||||
{placeholder}
|
{placeholder}
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
|
||||||
id={id}
|
|
||||||
inputRef={this.setTextbox}
|
|
||||||
className={textareaClasses}
|
|
||||||
disabled={disabled}
|
|
||||||
placeholder={placeholder}
|
|
||||||
autoFocus={false}
|
|
||||||
value={value}
|
|
||||||
onChange={this.onChange}
|
|
||||||
onKeyDown={this.onKeyDown}
|
|
||||||
onKeyUp={onKeyUp}
|
|
||||||
onFocus={this.onFocus}
|
|
||||||
onBlur={this.onBlur}
|
|
||||||
onPaste={this.onPaste}
|
|
||||||
aria-autocomplete='list'
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/*<Composer
|
<Composer
|
||||||
inputRef={this.setTextbox}
|
inputRef={this.setTextbox}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
value={value}
|
value={value}
|
||||||
|
valueMarkdown={valueMarkdown}
|
||||||
onChange={this.onChange}
|
onChange={this.onChange}
|
||||||
onKeyDown={this.onKeyDown}
|
onKeyDown={this.onKeyDown}
|
||||||
onKeyUp={onKeyUp}
|
onKeyUp={onKeyUp}
|
||||||
|
@ -276,7 +264,7 @@ export default class AutosuggestTextbox extends ImmutablePureComponent {
|
||||||
onBlur={this.onBlur}
|
onBlur={this.onBlur}
|
||||||
onPaste={this.onPaste}
|
onPaste={this.onPaste}
|
||||||
small={small}
|
small={small}
|
||||||
/>*/}
|
/>
|
||||||
|
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -7,8 +7,8 @@ import {
|
||||||
convertFromRaw,
|
convertFromRaw,
|
||||||
ContentState,
|
ContentState,
|
||||||
} from 'draft-js'
|
} from 'draft-js'
|
||||||
import { draftToMarkdown } from 'markdown-draft-js'
|
import draftToMarkdown from '../features/ui/util/draft-to-markdown'
|
||||||
// import draftToMarkdown from 'draftjs-to-markdown'
|
import markdownToDraft from '../features/ui/util/markdown-to-draft'
|
||||||
import { urlRegex } from '../features/ui/util/url_regex'
|
import { urlRegex } from '../features/ui/util/url_regex'
|
||||||
import classNames from 'classnames/bind'
|
import classNames from 'classnames/bind'
|
||||||
import RichTextEditorBar from './rich_text_editor_bar'
|
import RichTextEditorBar from './rich_text_editor_bar'
|
||||||
|
@ -30,11 +30,11 @@ function handleStrategy(contentBlock, callback, contentState) {
|
||||||
findWithRegex(HANDLE_REGEX, contentBlock, callback)
|
findWithRegex(HANDLE_REGEX, contentBlock, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
function hashtagStrategy (contentBlock, callback, contentState) {
|
function hashtagStrategy(contentBlock, callback, contentState) {
|
||||||
findWithRegex(HASHTAG_REGEX, contentBlock, callback)
|
findWithRegex(HASHTAG_REGEX, contentBlock, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
function urlStrategy (contentBlock, callback, contentState) {
|
function urlStrategy(contentBlock, callback, contentState) {
|
||||||
findWithRegex(urlRegex, contentBlock, callback)
|
findWithRegex(urlRegex, contentBlock, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,24 +73,17 @@ const compositeDecorator = new CompositeDecorator([
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
|
|
||||||
const HANDLE_REGEX = /\@[\w]+/g;
|
const HANDLE_REGEX = /\@[\w]+/g
|
||||||
const HASHTAG_REGEX = /\#[\w\u0590-\u05ff]+/g;
|
const HASHTAG_REGEX = /\#[\w\u0590-\u05ff]+/g
|
||||||
|
|
||||||
|
export default class Composer extends PureComponent {
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
|
||||||
|
|
||||||
})
|
|
||||||
|
|
||||||
export default
|
|
||||||
@connect(null, mapDispatchToProps)
|
|
||||||
class Composer extends PureComponent {
|
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
inputRef: PropTypes.func,
|
inputRef: PropTypes.func,
|
||||||
disabled: PropTypes.bool,
|
disabled: PropTypes.bool,
|
||||||
placeholder: PropTypes.string,
|
placeholder: PropTypes.string,
|
||||||
autoFocus: PropTypes.bool,
|
|
||||||
value: PropTypes.string,
|
value: PropTypes.string,
|
||||||
|
valueMarkdown: PropTypes.string,
|
||||||
onChange: PropTypes.func,
|
onChange: PropTypes.func,
|
||||||
onKeyDown: PropTypes.func,
|
onKeyDown: PropTypes.func,
|
||||||
onKeyUp: PropTypes.func,
|
onKeyUp: PropTypes.func,
|
||||||
|
@ -101,59 +94,69 @@ class Composer extends PureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
markdownText: '',
|
|
||||||
plainText: '',
|
|
||||||
editorState: EditorState.createEmpty(compositeDecorator),
|
editorState: EditorState.createEmpty(compositeDecorator),
|
||||||
}
|
plainText: this.props.value,
|
||||||
|
|
||||||
static getDerivedStateFromProps(nextProps, prevState) {
|
|
||||||
// if (!nextProps.isHidden && nextProps.isIntersecting && !prevState.fetched) {
|
|
||||||
// return {
|
|
||||||
// fetched: true
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
componentDidUpdate (prevProps) {
|
componentDidMount() {
|
||||||
// console.log("this.props.value:", this.props.value)
|
if (this.props.valueMarkdown) {
|
||||||
if (prevProps.value !== this.props.value) {
|
const rawData = markdownToDraft(this.props.valueMarkdown)
|
||||||
// const editorState = EditorState.push(this.state.editorState, ContentState.createFromText(this.props.value));
|
const contentState = convertFromRaw(rawData)
|
||||||
// this.setState({ editorState })
|
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,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// EditorState.createWithContent(ContentState.createFromText('Hello'))
|
componentDidUpdate() {
|
||||||
|
if (this.state.plainText !== this.props.value) {
|
||||||
onChange = (editorState) => {
|
let editorState
|
||||||
this.setState({ editorState })
|
if (!this.props.value) {
|
||||||
const content = this.state.editorState.getCurrentContent();
|
editorState = EditorState.createEmpty(compositeDecorator)
|
||||||
const text = content.getPlainText('\u0001')
|
} else {
|
||||||
|
editorState = EditorState.push(this.state.editorState, ContentState.createFromText(this.props.value))
|
||||||
// const selectionState = editorState.getSelection()
|
}
|
||||||
// const selectionStart = selectionState.getStartOffset()
|
this.setState({
|
||||||
|
editorState,
|
||||||
// const rawObject = convertToRaw(content);
|
plainText: this.props.value,
|
||||||
// const markdownString = draftToMarkdown(rawObject);
|
})
|
||||||
// const markdownString = draftToMarkdown(rawObject, {
|
}
|
||||||
// trigger: '#',
|
|
||||||
// separator: ' ',
|
|
||||||
// });
|
|
||||||
|
|
||||||
// console.log("text:", text, this.props.value)
|
|
||||||
|
|
||||||
this.props.onChange(null, text, selectionStart, markdownString)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// **bold**
|
onChange = (editorState) => {
|
||||||
// *italic*
|
const content = editorState.getCurrentContent()
|
||||||
// __underline__
|
const plainText = content.getPlainText('\u0001')
|
||||||
// ~strikethrough~
|
|
||||||
// # title
|
this.setState({ editorState, plainText })
|
||||||
// > quote
|
|
||||||
// `code`
|
const selectionState = editorState.getSelection()
|
||||||
// ```code```
|
const selectionStart = selectionState.getStartOffset()
|
||||||
|
|
||||||
|
const rawObject = convertToRaw(content)
|
||||||
|
const markdownString = draftToMarkdown(rawObject, {
|
||||||
|
escapeMarkdownCharacters: false,
|
||||||
|
preserveNewlines: false,
|
||||||
|
remarkablePreset: 'commonmark',
|
||||||
|
remarkableOptions: {
|
||||||
|
disable: {
|
||||||
|
block: ['table']
|
||||||
|
},
|
||||||
|
enable: {
|
||||||
|
inline: ['del', 'ins'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
this.props.onChange(null, plainText, markdownString, selectionStart)
|
||||||
|
}
|
||||||
|
|
||||||
focus = () => {
|
focus = () => {
|
||||||
this.textbox.editor.focus()
|
this.textbox.editor.focus()
|
||||||
|
@ -171,27 +174,24 @@ class Composer extends PureComponent {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
handleOnTogglePopoutEditor = () => {
|
|
||||||
//
|
|
||||||
}
|
|
||||||
|
|
||||||
onTab = (e) => {
|
onTab = (e) => {
|
||||||
const maxDepth = 4
|
const maxDepth = 4
|
||||||
this.onChange(RichUtils.onTab(e, this.state.editorState, maxDepth))
|
this.onChange(RichUtils.onTab(e, this.state.editorState, maxDepth))
|
||||||
}
|
}
|
||||||
|
|
||||||
setRef = (n) => {
|
setRef = (n) => {
|
||||||
this.textbox = n
|
try {
|
||||||
|
this.textbox = n
|
||||||
|
this.props.inputRef(n)
|
||||||
|
} catch (error) {
|
||||||
|
//
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const {
|
const {
|
||||||
inputRef,
|
|
||||||
disabled,
|
disabled,
|
||||||
placeholder,
|
placeholder,
|
||||||
autoFocus,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
onKeyDown,
|
onKeyDown,
|
||||||
onKeyUp,
|
onKeyUp,
|
||||||
onFocus,
|
onFocus,
|
||||||
|
@ -211,15 +211,13 @@ class Composer extends PureComponent {
|
||||||
pt15: !small,
|
pt15: !small,
|
||||||
px15: !small,
|
px15: !small,
|
||||||
px10: small,
|
px10: small,
|
||||||
pt5: small,
|
|
||||||
pb5: small,
|
|
||||||
pb10: !small,
|
pb10: !small,
|
||||||
})
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={_s.default}>
|
<div className={_s.default}>
|
||||||
|
|
||||||
{ /** : todo : */
|
{
|
||||||
!small &&
|
!small &&
|
||||||
<RichTextEditorBar
|
<RichTextEditorBar
|
||||||
editorState={editorState}
|
editorState={editorState}
|
||||||
|
@ -241,6 +239,9 @@ class Composer extends PureComponent {
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
ref={this.setRef}
|
ref={this.setRef}
|
||||||
readOnly={disabled}
|
readOnly={disabled}
|
||||||
|
onBlur={onBlur}
|
||||||
|
onFocus={onFocus}
|
||||||
|
stripPastedStyles
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -42,6 +42,7 @@ class ProUpgradeModal extends ImmutablePureComponent {
|
||||||
<Text>• Larger Video and Image Uploads</Text>
|
<Text>• Larger Video and Image Uploads</Text>
|
||||||
<Text>• Receive the PRO Badge</Text>
|
<Text>• Receive the PRO Badge</Text>
|
||||||
<Text>• Remove in-feed promotions</Text>
|
<Text>• Remove in-feed promotions</Text>
|
||||||
|
<Text>• Compose Rich Text posts (Bold, Italic, Underline and more)</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { RichUtils } from 'draft-js'
|
import { RichUtils } from 'draft-js'
|
||||||
import { defineMessages, injectIntl } from 'react-intl'
|
|
||||||
import classNames from 'classnames/bind'
|
import classNames from 'classnames/bind'
|
||||||
import { me } from '../initial_state'
|
import { me } from '../initial_state'
|
||||||
import { makeGetAccount } from '../selectors'
|
import { makeGetAccount } from '../selectors'
|
||||||
|
@ -39,41 +38,37 @@ const RTE_ITEMS = [
|
||||||
// icon: 'circle',
|
// icon: 'circle',
|
||||||
// },
|
// },
|
||||||
{
|
{
|
||||||
label: 'H1',
|
label: 'Title',
|
||||||
style: 'header-one',
|
style: 'header-one',
|
||||||
type: 'block',
|
type: 'block',
|
||||||
icon: 'text-size',
|
icon: 'text-size',
|
||||||
},
|
},
|
||||||
{
|
// {
|
||||||
label: 'Blockquote',
|
// label: 'Blockquote',
|
||||||
style: 'blockquote',
|
// style: 'blockquote',
|
||||||
type: 'block',
|
// type: 'block',
|
||||||
icon: 'blockquote',
|
// icon: 'blockquote',
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
label: 'Code Block',
|
// label: 'Code Block',
|
||||||
style: 'code-block',
|
// style: 'code-block',
|
||||||
type: 'block',
|
// type: 'block',
|
||||||
icon: 'code',
|
// icon: 'code',
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
label: 'UL',
|
// label: 'UL',
|
||||||
style: 'unordered-list-item',
|
// style: 'unordered-list-item',
|
||||||
type: 'block',
|
// type: 'block',
|
||||||
icon: 'ul-list',
|
// icon: 'ul-list',
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
label: 'OL',
|
// label: 'OL',
|
||||||
style: 'ordered-list-item',
|
// style: 'ordered-list-item',
|
||||||
type: 'block',
|
// type: 'block',
|
||||||
icon: 'ol-list',
|
// icon: 'ol-list',
|
||||||
},
|
// },
|
||||||
]
|
]
|
||||||
|
|
||||||
const messages = defineMessages({
|
|
||||||
follow: { id: 'follow', defaultMessage: 'Follow' },
|
|
||||||
})
|
|
||||||
|
|
||||||
const mapStateToProps = (state) => {
|
const mapStateToProps = (state) => {
|
||||||
const getAccount = makeGetAccount()
|
const getAccount = makeGetAccount()
|
||||||
const account = getAccount(state, me)
|
const account = getAccount(state, me)
|
||||||
|
@ -86,13 +81,11 @@ const mapStateToProps = (state) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
export default
|
export default
|
||||||
@injectIntl
|
|
||||||
@connect(mapStateToProps)
|
@connect(mapStateToProps)
|
||||||
class RichTextEditorBar extends PureComponent {
|
class RichTextEditorBar extends PureComponent {
|
||||||
|
|
||||||
static propTypes = {
|
static propTypes = {
|
||||||
editorState: PropTypes.object.isRequired,
|
editorState: PropTypes.object.isRequired,
|
||||||
intl: PropTypes.object.isRequired,
|
|
||||||
isPro: PropTypes.bool.isRequired,
|
isPro: PropTypes.bool.isRequired,
|
||||||
rteControlsVisible: PropTypes.bool.isRequired,
|
rteControlsVisible: PropTypes.bool.isRequired,
|
||||||
onChange: PropTypes.func.isRequired,
|
onChange: PropTypes.func.isRequired,
|
||||||
|
@ -127,7 +120,7 @@ class RichTextEditorBar extends PureComponent {
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
<Button
|
{/*<Button
|
||||||
backgroundColor='none'
|
backgroundColor='none'
|
||||||
color='secondary'
|
color='secondary'
|
||||||
onClick={this.handleOnTogglePopoutEditor}
|
onClick={this.handleOnTogglePopoutEditor}
|
||||||
|
@ -137,7 +130,7 @@ class RichTextEditorBar extends PureComponent {
|
||||||
iconClassName={_s.inheritFill}
|
iconClassName={_s.inheritFill}
|
||||||
iconSize='12px'
|
iconSize='12px'
|
||||||
radiusSmall
|
radiusSmall
|
||||||
/>
|
/>*/}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,7 +46,7 @@ class TimelineComposeBlock extends ImmutablePureComponent {
|
||||||
return (
|
return (
|
||||||
<section className={_s.default}>
|
<section className={_s.default}>
|
||||||
<div className={[_s.default, _s.flexRow].join(' ')}>
|
<div className={[_s.default, _s.flexRow].join(' ')}>
|
||||||
<ComposeFormContainer {...rest} />
|
<ComposeFormContainer {...rest} modal={modal} />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|
|
@ -21,6 +21,7 @@ import PollButton from './poll_button'
|
||||||
import PollForm from './poll_form'
|
import PollForm from './poll_form'
|
||||||
import SchedulePostButton from './schedule_post_button'
|
import SchedulePostButton from './schedule_post_button'
|
||||||
import SpoilerButton from './spoiler_button'
|
import SpoilerButton from './spoiler_button'
|
||||||
|
import RichTextEditorButton from './rich_text_editor_button'
|
||||||
import StatusContainer from '../../../containers/status_container'
|
import StatusContainer from '../../../containers/status_container'
|
||||||
import StatusVisibilityButton from './status_visibility_button'
|
import StatusVisibilityButton from './status_visibility_button'
|
||||||
import UploadButton from './media_upload_button'
|
import UploadButton from './media_upload_button'
|
||||||
|
@ -44,7 +45,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
static contextTypes = {
|
static contextTypes = {
|
||||||
router: PropTypes.object,
|
router: PropTypes.object,
|
||||||
}
|
}
|
||||||
|
|
||||||
state = {
|
state = {
|
||||||
composeFocused: false,
|
composeFocused: false,
|
||||||
}
|
}
|
||||||
|
@ -54,6 +55,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
edit: PropTypes.bool,
|
edit: PropTypes.bool,
|
||||||
isMatch: PropTypes.bool,
|
isMatch: PropTypes.bool,
|
||||||
text: PropTypes.string.isRequired,
|
text: PropTypes.string.isRequired,
|
||||||
|
markdown: PropTypes.string,
|
||||||
suggestions: ImmutablePropTypes.list,
|
suggestions: ImmutablePropTypes.list,
|
||||||
account: ImmutablePropTypes.map.isRequired,
|
account: ImmutablePropTypes.map.isRequired,
|
||||||
status: ImmutablePropTypes.map,
|
status: ImmutablePropTypes.map,
|
||||||
|
@ -92,14 +94,8 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
showSearch: false,
|
showSearch: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
handleChange = (e, markdown) => {
|
handleChange = (e, selectionStart) => {
|
||||||
let position = null
|
this.props.onChange(e.target.value, e.target.markdown, this.props.replyToId, selectionStart)
|
||||||
try {
|
|
||||||
position = this.autosuggestTextarea.textbox.selectionStart
|
|
||||||
} catch (error) {
|
|
||||||
//
|
|
||||||
}
|
|
||||||
this.props.onChange(e.target.value, markdown, this.props.replyToId, position)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleComposeFocus = () => {
|
handleComposeFocus = () => {
|
||||||
|
@ -137,11 +133,11 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSubmit = () => {
|
handleSubmit = () => {
|
||||||
if (this.props.text !== this.autosuggestTextarea.textbox.value) {
|
// if (this.props.text !== this.autosuggestTextarea.textbox.value) {
|
||||||
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
|
// Something changed the text inside the textarea (e.g. browser extensions like Grammarly)
|
||||||
// Update the state to match the current text
|
// Update the state to match the current text
|
||||||
this.props.onChange(this.autosuggestTextarea.textbox.value);
|
// this.props.onChange(this.autosuggestTextarea.textbox.value);
|
||||||
}
|
// }
|
||||||
|
|
||||||
// Submit disabled:
|
// Submit disabled:
|
||||||
const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props;
|
const { isSubmitting, isChangingUpload, isUploading, anyMedia } = this.props;
|
||||||
|
@ -218,6 +214,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEmojiPick = (data) => {
|
handleEmojiPick = (data) => {
|
||||||
|
// : todo : with rich text
|
||||||
const { text } = this.props
|
const { text } = this.props
|
||||||
const position = this.autosuggestTextarea.textbox.selectionStart
|
const position = this.autosuggestTextarea.textbox.selectionStart
|
||||||
const needsSpace = data.custom && position > 0 && !ALLOWED_AROUND_SHORT_CODE.includes(text[position - 1])
|
const needsSpace = data.custom && position > 0 && !ALLOWED_AROUND_SHORT_CODE.includes(text[position - 1])
|
||||||
|
@ -248,6 +245,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
isSubmitting,
|
isSubmitting,
|
||||||
selectedGifSrc,
|
selectedGifSrc,
|
||||||
} = this.props
|
} = this.props
|
||||||
|
|
||||||
const disabled = isSubmitting
|
const disabled = isSubmitting
|
||||||
const text = [this.props.spoilerText, countableText(this.props.text)].join('');
|
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);
|
const disabledButton = disabled || isUploading || isChangingUpload || length(text) > MAX_POST_CHARACTER_COUNT || (length(text) !== 0 && length(text.trim()) === 0 && !anyMedia);
|
||||||
|
@ -330,9 +328,9 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
<div className={actionsContainerClasses}>
|
<div className={actionsContainerClasses}>
|
||||||
<div className={[_s.default, _s.flexRow, _s.mrAuto].join(' ')}>
|
<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}>
|
<div className={commentPublishBtnClasses}>
|
||||||
<Button
|
<Button
|
||||||
|
@ -375,7 +373,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
>
|
>
|
||||||
|
|
||||||
{
|
{
|
||||||
!!reduxReplyToId && isModalOpen &&
|
!!reduxReplyToId && isModalOpen && isMatch &&
|
||||||
<div className={[_s.default, _s.px15, _s.py10, _s.mt5].join(' ')}>
|
<div className={[_s.default, _s.px15, _s.py10, _s.mt5].join(' ')}>
|
||||||
<StatusContainer
|
<StatusContainer
|
||||||
contextType='compose'
|
contextType='compose'
|
||||||
|
@ -405,6 +403,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
placeholder={intl.formatMessage((shouldCondense || !!reduxReplyToId) && isMatch ? messages.commentPlaceholder : messages.placeholder)}
|
placeholder={intl.formatMessage((shouldCondense || !!reduxReplyToId) && isMatch ? messages.commentPlaceholder : messages.placeholder)}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
value={this.props.text}
|
value={this.props.text}
|
||||||
|
valueMarkdown={this.props.markdown}
|
||||||
onChange={this.handleChange}
|
onChange={this.handleChange}
|
||||||
suggestions={this.props.suggestions}
|
suggestions={this.props.suggestions}
|
||||||
onKeyDown={this.handleKeyDown}
|
onKeyDown={this.handleKeyDown}
|
||||||
|
@ -449,7 +448,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
}
|
}
|
||||||
|
|
||||||
{
|
{
|
||||||
!!quoteOfId && isModalOpen &&
|
!!quoteOfId && isModalOpen && isMatch &&
|
||||||
<div className={[_s.default, _s.px15, _s.py10, _s.mt5].join(' ')}>
|
<div className={[_s.default, _s.px15, _s.py10, _s.mt5].join(' ')}>
|
||||||
<StatusContainer
|
<StatusContainer
|
||||||
contextType='compose'
|
contextType='compose'
|
||||||
|
@ -476,7 +475,7 @@ class ComposeForm extends ImmutablePureComponent {
|
||||||
<StatusVisibilityButton />
|
<StatusVisibilityButton />
|
||||||
<SpoilerButton />
|
<SpoilerButton />
|
||||||
<SchedulePostButton />
|
<SchedulePostButton />
|
||||||
{ /* !shouldCondense && <RichTextEditorButton /> */}
|
<RichTextEditorButton />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Responsive min={BREAKPOINT_EXTRA_SMALL}>
|
<Responsive min={BREAKPOINT_EXTRA_SMALL}>
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { injectIntl, defineMessages } from 'react-intl'
|
import { injectIntl, defineMessages } from 'react-intl'
|
||||||
import { changeRichTextEditorControlsVisibility } from '../../../actions/compose'
|
import { changeRichTextEditorControlsVisibility } from '../../../actions/compose'
|
||||||
|
import { openModal } from '../../../actions/modal'
|
||||||
|
import { me } from '../../../initial_state'
|
||||||
import ComposeExtraButton from './compose_extra_button'
|
import ComposeExtraButton from './compose_extra_button'
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
|
@ -10,14 +12,18 @@ const messages = defineMessages({
|
||||||
|
|
||||||
const mapStateToProps = (state) => ({
|
const mapStateToProps = (state) => ({
|
||||||
active: state.getIn(['compose', 'rte_controls_visible']),
|
active: state.getIn(['compose', 'rte_controls_visible']),
|
||||||
|
isPro: state.getIn(['accounts', me, 'is_pro']),
|
||||||
})
|
})
|
||||||
|
|
||||||
const mapDispatchToProps = (dispatch) => ({
|
const mapDispatchToProps = (dispatch) => ({
|
||||||
|
|
||||||
onClick (status) {
|
onChangeRichTextEditorControlsVisibility() {
|
||||||
dispatch(changeRichTextEditorControlsVisibility(status))
|
dispatch(changeRichTextEditorControlsVisibility())
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onOpenProUpgradeModal() {
|
||||||
|
dispatch(openModal('PRO_UPGRADE'))
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
export default
|
export default
|
||||||
|
@ -29,14 +35,21 @@ class RichTextEditorButton extends PureComponent {
|
||||||
active: PropTypes.bool,
|
active: PropTypes.bool,
|
||||||
intl: PropTypes.object.isRequired,
|
intl: PropTypes.object.isRequired,
|
||||||
small: PropTypes.bool,
|
small: PropTypes.bool,
|
||||||
|
isPro: PropTypes.bool,
|
||||||
|
onOpenProUpgradeModal: PropTypes.func.isRequired,
|
||||||
|
onChangeRichTextEditorControlsVisibility: PropTypes.func.isRequired,
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClick = (e) => {
|
handleClick = (e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
this.props.onClick()
|
if (!this.props.isPro) {
|
||||||
|
this.props.onOpenProUpgradeModal()
|
||||||
|
} else {
|
||||||
|
this.props.onChangeRichTextEditorControlsVisibility()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
render () {
|
render() {
|
||||||
const { active, intl, small } = this.props
|
const { active, intl, small } = this.props
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
@ -12,7 +12,13 @@ import {
|
||||||
} from '../../../actions/compose'
|
} from '../../../actions/compose'
|
||||||
import { me } from '../../../initial_state'
|
import { me } from '../../../initial_state'
|
||||||
|
|
||||||
const mapStateToProps = (state, { replyToId, isStandalone }) => {
|
const mapStateToProps = (state, props) => {
|
||||||
|
const {
|
||||||
|
replyToId,
|
||||||
|
isStandalone,
|
||||||
|
shouldCondense,
|
||||||
|
modal,
|
||||||
|
} = props
|
||||||
|
|
||||||
const reduxReplyToId = state.getIn(['compose', 'in_reply_to'])
|
const reduxReplyToId = state.getIn(['compose', 'in_reply_to'])
|
||||||
const isModalOpen = state.getIn(['modal', 'modalType']) === 'COMPOSE' || isStandalone
|
const isModalOpen = state.getIn(['modal', 'modalType']) === 'COMPOSE' || isStandalone
|
||||||
|
@ -27,10 +33,14 @@ const mapStateToProps = (state, { replyToId, isStandalone }) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isModalOpen) isMatch = true
|
if (isModalOpen) isMatch = true
|
||||||
|
if (isModalOpen && shouldCondense) isMatch = false
|
||||||
|
if (isModalOpen && !modal) isMatch = false
|
||||||
|
|
||||||
// console.log("isMatch:", isMatch, reduxReplyToId, replyToId, state.getIn(['compose', 'text']))
|
// console.log("isMatch:", isMatch, reduxReplyToId, replyToId, state.getIn(['compose', 'text']))
|
||||||
// console.log("reduxReplyToId:", reduxReplyToId, isModalOpen, isStandalone)
|
// console.log("reduxReplyToId:", reduxReplyToId, isModalOpen, isStandalone)
|
||||||
|
|
||||||
|
const edit = state.getIn(['compose', 'id'])
|
||||||
|
|
||||||
if (!isMatch) {
|
if (!isMatch) {
|
||||||
return {
|
return {
|
||||||
isMatch,
|
isMatch,
|
||||||
|
@ -38,6 +48,7 @@ const mapStateToProps = (state, { replyToId, isStandalone }) => {
|
||||||
reduxReplyToId,
|
reduxReplyToId,
|
||||||
edit: null,
|
edit: null,
|
||||||
text: '',
|
text: '',
|
||||||
|
markdown: null,
|
||||||
suggestions: ImmutableList(),
|
suggestions: ImmutableList(),
|
||||||
spoiler: false,
|
spoiler: false,
|
||||||
spoilerText: '',
|
spoilerText: '',
|
||||||
|
@ -57,13 +68,14 @@ const mapStateToProps = (state, { replyToId, isStandalone }) => {
|
||||||
selectedGifSrc: null,
|
selectedGifSrc: null,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isMatch,
|
isMatch,
|
||||||
isModalOpen,
|
isModalOpen,
|
||||||
reduxReplyToId,
|
reduxReplyToId,
|
||||||
edit: state.getIn(['compose', 'id']) !== null,
|
edit: state.getIn(['compose', 'id']) !== null,
|
||||||
text: state.getIn(['compose', 'text']),
|
text: state.getIn(['compose', 'text']),
|
||||||
|
markdown: state.getIn(['compose', 'markdown']),
|
||||||
suggestions: state.getIn(['compose', 'suggestions']),
|
suggestions: state.getIn(['compose', 'suggestions']),
|
||||||
spoiler: state.getIn(['compose', 'spoiler']),
|
spoiler: state.getIn(['compose', 'spoiler']),
|
||||||
spoilerText: state.getIn(['compose', 'spoiler_text']),
|
spoilerText: state.getIn(['compose', 'spoiler_text']),
|
||||||
|
|
|
@ -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;
|
|
@ -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 there’s nested block level items deeper than that, we need to make sure we capture this by cloning the topmost block
|
||||||
|
// otherwise we’ll 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;
|
|
@ -100,6 +100,7 @@ function clearAll(state) {
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.set('id', null);
|
map.set('id', null);
|
||||||
map.set('text', '');
|
map.set('text', '');
|
||||||
|
map.set('markdown', null);
|
||||||
map.set('spoiler', false);
|
map.set('spoiler', false);
|
||||||
map.set('spoiler_text', '');
|
map.set('spoiler_text', '');
|
||||||
map.set('is_submitting', false);
|
map.set('is_submitting', false);
|
||||||
|
@ -112,6 +113,8 @@ function clearAll(state) {
|
||||||
map.set('poll', null);
|
map.set('poll', null);
|
||||||
map.set('idempotencyKey', uuid());
|
map.set('idempotencyKey', uuid());
|
||||||
map.set('scheduled_at', null);
|
map.set('scheduled_at', null);
|
||||||
|
map.set('rte_controls_visible', false);
|
||||||
|
map.set('gif', false);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -271,6 +274,7 @@ export default function compose(state = initialState, action) {
|
||||||
map.set('idempotencyKey', uuid());
|
map.set('idempotencyKey', uuid());
|
||||||
map.set('spoiler', false);
|
map.set('spoiler', false);
|
||||||
map.set('spoiler_text', '');
|
map.set('spoiler_text', '');
|
||||||
|
map.set('rte_controls_visible', false);
|
||||||
if (action.text) {
|
if (action.text) {
|
||||||
map.set('text', `${statusToTextMentions(state, action.status)}${action.text}`);
|
map.set('text', `${statusToTextMentions(state, action.status)}${action.text}`);
|
||||||
} else {
|
} else {
|
||||||
|
@ -289,6 +293,7 @@ export default function compose(state = initialState, action) {
|
||||||
map.set('idempotencyKey', uuid());
|
map.set('idempotencyKey', uuid());
|
||||||
map.set('spoiler', false);
|
map.set('spoiler', false);
|
||||||
map.set('spoiler_text', '');
|
map.set('spoiler_text', '');
|
||||||
|
map.set('rte_controls_visible', '');
|
||||||
});
|
});
|
||||||
case COMPOSE_REPLY_CANCEL:
|
case COMPOSE_REPLY_CANCEL:
|
||||||
case COMPOSE_RESET:
|
case COMPOSE_RESET:
|
||||||
|
@ -361,9 +366,11 @@ export default function compose(state = initialState, action) {
|
||||||
return item;
|
return item;
|
||||||
}));
|
}));
|
||||||
case STATUS_EDIT:
|
case STATUS_EDIT:
|
||||||
|
const hasMarkdown = !!action.status.get('plain_markdown')
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.set('id', action.status.get('id'));
|
map.set('id', action.status.get('id'));
|
||||||
map.set('text', unescapeHTML(expandMentions(action.status)));
|
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('in_reply_to', action.status.get('in_reply_to_id'));
|
||||||
map.set('quote_of_id', action.status.get('quote_of_id'));
|
map.set('quote_of_id', action.status.get('quote_of_id'));
|
||||||
map.set('privacy', action.status.get('visibility'));
|
map.set('privacy', action.status.get('visibility'));
|
||||||
|
@ -371,6 +378,7 @@ export default function compose(state = initialState, action) {
|
||||||
map.set('focusDate', new Date());
|
map.set('focusDate', new Date());
|
||||||
map.set('caretPosition', null);
|
map.set('caretPosition', null);
|
||||||
map.set('idempotencyKey', uuid());
|
map.set('idempotencyKey', uuid());
|
||||||
|
map.set('rte_controls_visible', hasMarkdown);
|
||||||
|
|
||||||
if (action.status.get('spoiler_text').length > 0) {
|
if (action.status.get('spoiler_text').length > 0) {
|
||||||
map.set('spoiler', true);
|
map.set('spoiler', true);
|
||||||
|
@ -396,7 +404,7 @@ export default function compose(state = initialState, action) {
|
||||||
return state.set('scheduled_at', action.date);
|
return state.set('scheduled_at', action.date);
|
||||||
case COMPOSE_RICH_TEXT_EDITOR_CONTROLS_VISIBILITY:
|
case COMPOSE_RICH_TEXT_EDITOR_CONTROLS_VISIBILITY:
|
||||||
return state.withMutations(map => {
|
return state.withMutations(map => {
|
||||||
map.set('rte_controls_visible', !state.get('rte_controls_visible'));
|
map.set('rte_controls_visible', action.open || !state.get('rte_controls_visible'));
|
||||||
});
|
});
|
||||||
default:
|
default:
|
||||||
return state;
|
return state;
|
||||||
|
|
|
@ -122,6 +122,11 @@ body {
|
||||||
overscroll-behavior-y: none;
|
overscroll-behavior-y: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
code,
|
||||||
|
pre {
|
||||||
|
font-family: monospace !important;
|
||||||
|
}
|
||||||
|
|
||||||
.overflowYScroll {
|
.overflowYScroll {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
|
@ -166,11 +171,29 @@ body {
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statusContent h1 * {
|
||||||
|
font-size: var(--fs_xl) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.statusContent ul,
|
.statusContent ul,
|
||||||
.statusContent ol {
|
.statusContent ol {
|
||||||
list-style-type: disc;
|
|
||||||
padding-left: 40px;
|
padding-left: 40px;
|
||||||
margin: 0;
|
margin: 0.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusContent ul {
|
||||||
|
/* list-style-type: disc; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusContent ol {
|
||||||
|
/* list-style-type: disc; */
|
||||||
|
}
|
||||||
|
|
||||||
|
.statusContent code {
|
||||||
|
background-color: var(--border_color_secondary);
|
||||||
|
color: var(--text_color_secondary) !important;
|
||||||
|
font-size: var(--fs_n) !important;
|
||||||
|
padding: 0.25em 0.5em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dangerousContent,
|
.dangerousContent,
|
||||||
|
@ -1009,6 +1032,7 @@ body {
|
||||||
font-size: var(--fs_l);
|
font-size: var(--fs_l);
|
||||||
} */
|
} */
|
||||||
|
|
||||||
|
.statusContent blockquote,
|
||||||
:global(.RichEditor-blockquote) {
|
:global(.RichEditor-blockquote) {
|
||||||
border-left: 5px solid var(--border_color_secondary);
|
border-left: 5px solid var(--border_color_secondary);
|
||||||
color: var(--text_color_secondary);
|
color: var(--text_color_secondary);
|
||||||
|
@ -1017,11 +1041,14 @@ body {
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.statusContent pre,
|
||||||
:global(.public-DraftStyleDefault-pre) {
|
:global(.public-DraftStyleDefault-pre) {
|
||||||
background-color: rgba(0,0,0,.05);
|
background-color: rgba(0,0,0,.05);
|
||||||
font-family: 'Inconsolata', 'Menlo', 'Consolas', monospace;
|
font-family: 'Inconsolata', 'Menlo', 'Consolas', monospace;
|
||||||
font-size: var(--fs_l);
|
font-size: var(--fs_l);
|
||||||
padding: 10px 20px;
|
padding: 10px 20px;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* */
|
/* */
|
||||||
|
|
|
@ -3,6 +3,73 @@
|
||||||
require 'singleton'
|
require 'singleton'
|
||||||
require_relative './sanitize_config'
|
require_relative './sanitize_config'
|
||||||
|
|
||||||
|
class HTMLRenderer < Redcarpet::Render::HTML
|
||||||
|
def block_code(code, language)
|
||||||
|
"<pre><code>#{encode(code).gsub("\n", "<br/>")}</code></pre>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def block_quote(quote)
|
||||||
|
"<blockquote>#{quote}</blockquote>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def codespan(code)
|
||||||
|
"<code>#{code}</code>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def double_emphasis(text)
|
||||||
|
"<strong>#{text}</strong>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def emphasis(text)
|
||||||
|
"<em>#{text}</em>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def header(text, header_level)
|
||||||
|
"<h1>#{text}</h1>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def triple_emphasis(text)
|
||||||
|
"<b><em>#{text}</em></b>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def strikethrough(text)
|
||||||
|
"<del>#{text}</del>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def underline(text)
|
||||||
|
"<u>#{text}</u>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def list(contents, list_type)
|
||||||
|
if list_type == :ordered
|
||||||
|
"<ol>#{contents}</ol>"
|
||||||
|
elsif list_type == :unordered
|
||||||
|
"<ul>#{contents}</ul>"
|
||||||
|
else
|
||||||
|
content
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def list_item(text, list_type)
|
||||||
|
"<li>#{text}</li>"
|
||||||
|
end
|
||||||
|
|
||||||
|
def autolink(link, link_type)
|
||||||
|
return link if link_type == :email
|
||||||
|
Formatter.instance.link_url(link)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def html_entities
|
||||||
|
@html_entities ||= HTMLEntities.new
|
||||||
|
end
|
||||||
|
|
||||||
|
def encode(html)
|
||||||
|
html_entities.encode(html)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
class Formatter
|
class Formatter
|
||||||
include Singleton
|
include Singleton
|
||||||
include RoutingHelper
|
include RoutingHelper
|
||||||
|
@ -39,33 +106,28 @@ class Formatter
|
||||||
linkable_accounts << status.account
|
linkable_accounts << status.account
|
||||||
|
|
||||||
html = raw_content
|
html = raw_content
|
||||||
|
html = format_markdown(html) if options[:use_markdown]
|
||||||
html = encode_and_link_urls(html, linkable_accounts)
|
html = encode_and_link_urls(html, linkable_accounts, keep_html: options[:use_markdown])
|
||||||
|
html = reformat(html, true) unless options[:use_markdown]
|
||||||
# : todo :
|
|
||||||
if options[:use_markdown]
|
|
||||||
html = convert_headers(html)
|
|
||||||
html = convert_strong(html)
|
|
||||||
html = convert_italic(html)
|
|
||||||
html = convert_strikethrough(html)
|
|
||||||
html = convert_code(html)
|
|
||||||
html = convert_codeblock(html)
|
|
||||||
html = convert_links(html)
|
|
||||||
html = convert_lists(html)
|
|
||||||
html = convert_ordered_lists(html)
|
|
||||||
end
|
|
||||||
|
|
||||||
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
|
html = encode_custom_emojis(html, status.emojis, options[:autoplay]) if options[:custom_emojify]
|
||||||
|
|
||||||
html = simple_format(html, {}, sanitize: false)
|
unless options[:use_markdown]
|
||||||
|
html = html.gsub(/(?:\n\r?|\r\n?)/, '<br />')
|
||||||
html = html.delete("\n")
|
html = html.delete("\n")
|
||||||
|
end
|
||||||
|
|
||||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
html.html_safe # rubocop:disable Rails/OutputSafety
|
||||||
|
|
||||||
|
html
|
||||||
end
|
end
|
||||||
|
|
||||||
def reformat(html)
|
def format_markdown(html)
|
||||||
sanitize(html, Sanitize::Config::GABSOCIAL_STRICT)
|
html = markdown_formatter.render(html)
|
||||||
|
html.delete("\r").delete("\n")
|
||||||
|
end
|
||||||
|
|
||||||
|
def reformat(html, outgoing = false)
|
||||||
|
sanitize(html, Sanitize::Config::GABSOCIAL_STRICT.merge(outgoing: outgoing))
|
||||||
rescue ArgumentError
|
rescue ArgumentError
|
||||||
''
|
''
|
||||||
end
|
end
|
||||||
|
@ -106,8 +168,7 @@ class Formatter
|
||||||
end
|
end
|
||||||
|
|
||||||
def format_field(account, str, **options)
|
def format_field(account, str, **options)
|
||||||
return reformat(str).html_safe unless account.local? # rubocop:disable Rails/OutputSafety
|
html = account.local? ? encode_and_link_urls(str, me: true) : reformat(str)
|
||||||
html = encode_and_link_urls(str, me: true)
|
|
||||||
html = encode_custom_emojis(html, account.emojis, options[:autoplay]) if options[:custom_emojify]
|
html = encode_custom_emojis(html, account.emojis, options[:autoplay]) if options[:custom_emojify]
|
||||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
html.html_safe # rubocop:disable Rails/OutputSafety
|
||||||
end
|
end
|
||||||
|
@ -120,8 +181,43 @@ class Formatter
|
||||||
html.html_safe # rubocop:disable Rails/OutputSafety
|
html.html_safe # rubocop:disable Rails/OutputSafety
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def link_url(url)
|
||||||
|
"<a href=\"#{encode(url)}\" target=\"blank\" rel=\"nofollow noopener noreferrer\">#{link_html(url)}</a>"
|
||||||
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
|
def markdown_formatter
|
||||||
|
extensions = {
|
||||||
|
autolink: false,
|
||||||
|
no_intra_emphasis: true,
|
||||||
|
fenced_code_blocks: true,
|
||||||
|
disable_indented_code_blocks: true,
|
||||||
|
strikethrough: true,
|
||||||
|
lax_spacing: true,
|
||||||
|
space_after_headers: true,
|
||||||
|
superscript: false,
|
||||||
|
underline: true,
|
||||||
|
highlight: false,
|
||||||
|
footnotes: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
renderer = HTMLRenderer.new({
|
||||||
|
filter_html: false,
|
||||||
|
escape_html: false,
|
||||||
|
no_images: true,
|
||||||
|
no_styles: true,
|
||||||
|
safe_links_only: false,
|
||||||
|
hard_wrap: false,
|
||||||
|
no_links: true,
|
||||||
|
with_toc_data: false,
|
||||||
|
prettify: false,
|
||||||
|
link_attributes: nil
|
||||||
|
})
|
||||||
|
|
||||||
|
Redcarpet::Markdown.new(renderer, extensions)
|
||||||
|
end
|
||||||
|
|
||||||
def html_entities
|
def html_entities
|
||||||
@html_entities ||= HTMLEntities.new
|
@html_entities ||= HTMLEntities.new
|
||||||
end
|
end
|
||||||
|
@ -138,7 +234,7 @@ class Formatter
|
||||||
accounts = nil
|
accounts = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
rewrite(html.dup, entities) do |entity|
|
rewrite(html.dup, entities, options[:keep_html]) do |entity|
|
||||||
if entity[:url]
|
if entity[:url]
|
||||||
link_to_url(entity, options)
|
link_to_url(entity, options)
|
||||||
elsif entity[:hashtag]
|
elsif entity[:hashtag]
|
||||||
|
@ -208,7 +304,7 @@ class Formatter
|
||||||
html
|
html
|
||||||
end
|
end
|
||||||
|
|
||||||
def rewrite(text, entities)
|
def rewrite(text, entities, keep_html = false)
|
||||||
text = text.to_s
|
text = text.to_s
|
||||||
|
|
||||||
# Sort by start index
|
# Sort by start index
|
||||||
|
@ -221,12 +317,12 @@ class Formatter
|
||||||
|
|
||||||
last_index = entities.reduce(0) do |index, entity|
|
last_index = entities.reduce(0) do |index, entity|
|
||||||
indices = entity.respond_to?(:indices) ? entity.indices : entity[:indices]
|
indices = entity.respond_to?(:indices) ? entity.indices : entity[:indices]
|
||||||
result << encode(text[index...indices.first])
|
result << (keep_html ? text[index...indices.first] : encode(text[index...indices.first]))
|
||||||
result << yield(entity)
|
result << yield(entity)
|
||||||
indices.last
|
indices.last
|
||||||
end
|
end
|
||||||
|
|
||||||
result << encode(text[last_index..-1])
|
result << (keep_html ? text[last_index..-1] : encode(text[last_index..-1]))
|
||||||
|
|
||||||
result.flatten.join
|
result.flatten.join
|
||||||
end
|
end
|
||||||
|
@ -269,6 +365,29 @@ class Formatter
|
||||||
Extractor.remove_overlapping_entities(special + standard)
|
Extractor.remove_overlapping_entities(special + standard)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def html_friendly_extractor(html, options = {})
|
||||||
|
gaps = []
|
||||||
|
total_offset = 0
|
||||||
|
|
||||||
|
escaped = html.gsub(/<[^>]*>|&#[0-9]+;/) do |match|
|
||||||
|
total_offset += match.length - 1
|
||||||
|
end_offset = Regexp.last_match.end(0)
|
||||||
|
gaps << [end_offset - total_offset, total_offset]
|
||||||
|
"\u200b"
|
||||||
|
end
|
||||||
|
|
||||||
|
entities = Extractor.extract_hashtags_with_indices(escaped, :check_url_overlap => false) +
|
||||||
|
Extractor.extract_mentions_or_lists_with_indices(escaped)
|
||||||
|
Extractor.remove_overlapping_entities(entities).map do |extract|
|
||||||
|
pos = extract[:indices].first
|
||||||
|
offset_idx = gaps.rindex { |gap| gap.first <= pos }
|
||||||
|
offset = offset_idx.nil? ? 0 : gaps[offset_idx].last
|
||||||
|
next extract.merge(
|
||||||
|
:indices => [extract[:indices].first + offset, extract[:indices].last + offset]
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def link_to_url(entity, options = {})
|
def link_to_url(entity, options = {})
|
||||||
url = Addressable::URI.parse(entity[:url])
|
url = Addressable::URI.parse(entity[:url])
|
||||||
html_attrs = { target: '_blank', rel: 'nofollow noopener noreferrer' }
|
html_attrs = { target: '_blank', rel: 'nofollow noopener noreferrer' }
|
||||||
|
@ -321,79 +440,4 @@ class Formatter
|
||||||
"<a data-focusable=\"true\" role=\"link\" href=\"#{encode(TagManager.instance.url_for(account))}\" class=\"u-url mention\">@#{encode(account.acct)}</a>"
|
"<a data-focusable=\"true\" role=\"link\" href=\"#{encode(TagManager.instance.url_for(account))}\" class=\"u-url mention\">@#{encode(account.acct)}</a>"
|
||||||
end
|
end
|
||||||
|
|
||||||
def convert_headers(html)
|
end
|
||||||
html.gsub(/^\#{1,6}.*$/) do |header|
|
|
||||||
weight = 0
|
|
||||||
header.split('').each do |char|
|
|
||||||
break unless char == '#'
|
|
||||||
weight += 1
|
|
||||||
end
|
|
||||||
content = header.sub(/^\#{1,6}/, '')
|
|
||||||
"<h#{weight}>#{content}</h#{weight}>"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def convert_strong(html)
|
|
||||||
html.gsub(/\*{2}.*\*{2}|_{2}.*_{2}/) do |strong|
|
|
||||||
content = strong.gsub(/\*{2}|_{2}/, '')
|
|
||||||
"<strong>#{content}</strong>"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def convert_italic(html)
|
|
||||||
html.gsub(/\*{1}(\w|\s)+\*{1}|_{1}(\w|\s)+_{1}/) do |italic|
|
|
||||||
content = italic.gsub(/\*{1}|_{1}/, '')
|
|
||||||
"<em>#{content}</em>"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def convert_strikethrough(html)
|
|
||||||
html.gsub(/~~(\w|\s)+~~/) do |strike|
|
|
||||||
content = strike.gsub(/~~/, '')
|
|
||||||
"<strike>#{content}</strike>"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def convert_code(html)
|
|
||||||
html.gsub(/`(\w|\s)+`/) do |code|
|
|
||||||
content = code.gsub(/`/, '')
|
|
||||||
"<code>#{content}</code>"
|
|
||||||
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(/`/, '')
|
|
||||||
"<pre class=\"#{lang}\"><code>#{content}</code></pre>"
|
|
||||||
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(/\(|\)/, '')
|
|
||||||
"<a href=\"#{href}\">#{link_text}</a>"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def convert_lists(html)
|
|
||||||
html.gsub(/(\-.+(\r|\n|\r\n))+/) do |list|
|
|
||||||
items = "<ul>\n"
|
|
||||||
list.gsub(/\-.+/) do |li|
|
|
||||||
items << "<li>#{li.sub(/^\-/, '').strip}</li>\n"
|
|
||||||
end
|
|
||||||
items << "</ul>\n"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def convert_ordered_lists(html)
|
|
||||||
html.gsub(/(\d\..+(\r|\n|\r\n))+/) do |list|
|
|
||||||
items = "<ol>\n"
|
|
||||||
list.gsub(/\d.+/) do |li|
|
|
||||||
items << "<li>#{li.sub(/^\d\./, '').strip}</li>\n"
|
|
||||||
end
|
|
||||||
items << "</ol>\n"
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
|
@ -13,6 +13,7 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
||||||
|
|
||||||
attribute :content, unless: :source_requested?
|
attribute :content, unless: :source_requested?
|
||||||
attribute :rich_content, unless: :source_requested?
|
attribute :rich_content, unless: :source_requested?
|
||||||
|
attribute :plain_markdown, unless: :source_requested?
|
||||||
attribute :text, if: :source_requested?
|
attribute :text, if: :source_requested?
|
||||||
|
|
||||||
belongs_to :reblog, serializer: REST::StatusSerializer
|
belongs_to :reblog, serializer: REST::StatusSerializer
|
||||||
|
@ -73,8 +74,11 @@ class REST::StatusSerializer < ActiveModel::Serializer
|
||||||
end
|
end
|
||||||
|
|
||||||
def rich_content
|
def rich_content
|
||||||
Formatter.instance.format(object).strip
|
Formatter.instance.format(object, use_markdown: true).strip
|
||||||
# Formatter.instance.format(object, use_markdown: true).strip
|
end
|
||||||
|
|
||||||
|
def plain_markdown
|
||||||
|
object.markdown
|
||||||
end
|
end
|
||||||
|
|
||||||
def url
|
def url
|
||||||
|
|
|
@ -7,6 +7,7 @@ class EditStatusService < BaseService
|
||||||
# @param [Status] status Status being edited
|
# @param [Status] status Status being edited
|
||||||
# @param [Hash] options
|
# @param [Hash] options
|
||||||
# @option [String] :text Message
|
# @option [String] :text Message
|
||||||
|
# @option [String] :markdown Optional message in markdown
|
||||||
# @option [Boolean] :sensitive
|
# @option [Boolean] :sensitive
|
||||||
# @option [String] :visibility
|
# @option [String] :visibility
|
||||||
# @option [String] :spoiler_text
|
# @option [String] :spoiler_text
|
||||||
|
@ -20,6 +21,7 @@ class EditStatusService < BaseService
|
||||||
@account = status.account
|
@account = status.account
|
||||||
@options = options
|
@options = options
|
||||||
@text = @options[:text] || ''
|
@text = @options[:text] || ''
|
||||||
|
@markdown = @options[:markdown] if @account.is_pro
|
||||||
|
|
||||||
return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
|
return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
|
||||||
|
|
||||||
|
@ -119,6 +121,7 @@ class EditStatusService < BaseService
|
||||||
{
|
{
|
||||||
revised_at: Time.now,
|
revised_at: Time.now,
|
||||||
text: @text,
|
text: @text,
|
||||||
|
markdown: @markdown,
|
||||||
media_attachments: @media || [],
|
media_attachments: @media || [],
|
||||||
sensitive: (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?,
|
sensitive: (@options[:sensitive].nil? ? @account.user&.setting_default_sensitive : @options[:sensitive]) || @options[:spoiler_text].present?,
|
||||||
spoiler_text: @options[:spoiler_text] || '',
|
spoiler_text: @options[:spoiler_text] || '',
|
||||||
|
|
|
@ -26,7 +26,7 @@ class PostStatusService < BaseService
|
||||||
@account = account
|
@account = account
|
||||||
@options = options
|
@options = options
|
||||||
@text = @options[:text] || ''
|
@text = @options[:text] || ''
|
||||||
@markdown = @options[:markdown]
|
@markdown = @options[:markdown] if @account.is_pro
|
||||||
@in_reply_to = @options[:thread]
|
@in_reply_to = @options[:thread]
|
||||||
|
|
||||||
return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
|
return idempotency_duplicate if idempotency_given? && idempotency_duplicate?
|
||||||
|
|
|
@ -114,7 +114,6 @@
|
||||||
"lodash.throttle": "^4.1.1",
|
"lodash.throttle": "^4.1.1",
|
||||||
"lodash.unescape": "^4.0.1",
|
"lodash.unescape": "^4.0.1",
|
||||||
"mark-loader": "^0.1.6",
|
"mark-loader": "^0.1.6",
|
||||||
"markdown-draft-js": "^2.2.0",
|
|
||||||
"mini-css-extract-plugin": "^0.5.0",
|
"mini-css-extract-plugin": "^0.5.0",
|
||||||
"mkdirp": "^0.5.1",
|
"mkdirp": "^0.5.1",
|
||||||
"moment-mini": "^2.24.0",
|
"moment-mini": "^2.24.0",
|
||||||
|
@ -151,6 +150,7 @@
|
||||||
"redux": "^4.0.1",
|
"redux": "^4.0.1",
|
||||||
"redux-immutable": "^4.0.0",
|
"redux-immutable": "^4.0.0",
|
||||||
"redux-thunk": "^2.2.0",
|
"redux-thunk": "^2.2.0",
|
||||||
|
"remarkable": "^2.0.1",
|
||||||
"requestidlecallback": "^0.3.0",
|
"requestidlecallback": "^0.3.0",
|
||||||
"reselect": "^4.0.0",
|
"reselect": "^4.0.0",
|
||||||
"rimraf": "^2.6.3",
|
"rimraf": "^2.6.3",
|
||||||
|
|
22
yarn.lock
22
yarn.lock
|
@ -6014,13 +6014,6 @@ mark-loader@^0.1.6:
|
||||||
resolved "https://registry.yarnpkg.com/mark-loader/-/mark-loader-0.1.6.tgz#0abb477dca7421d70e20128ff6489f5cae8676d5"
|
resolved "https://registry.yarnpkg.com/mark-loader/-/mark-loader-0.1.6.tgz#0abb477dca7421d70e20128ff6489f5cae8676d5"
|
||||||
integrity sha1-CrtHfcp0IdcOIBKP9kifXK6GdtU=
|
integrity sha1-CrtHfcp0IdcOIBKP9kifXK6GdtU=
|
||||||
|
|
||||||
markdown-draft-js@^2.2.0:
|
|
||||||
version "2.2.0"
|
|
||||||
resolved "https://registry.yarnpkg.com/markdown-draft-js/-/markdown-draft-js-2.2.0.tgz#1a4796c4d7907761ae417e2176ab858dfaa1166a"
|
|
||||||
integrity sha512-OW53nd5m7KrnuXjH8jNKB3SQ5KidKD/+pHS+3daVKTsyjsiozghjUxzNH+5JLxzaBECHmJ7itE39RzMgYm5EFQ==
|
|
||||||
dependencies:
|
|
||||||
remarkable "2.0.0"
|
|
||||||
|
|
||||||
md5.js@^1.3.4:
|
md5.js@^1.3.4:
|
||||||
version "1.3.5"
|
version "1.3.5"
|
||||||
resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f"
|
resolved "https://registry.yarnpkg.com/md5.js/-/md5.js-1.3.5.tgz#b5d07b8e3216e3e27cd728d72f70d1e6a342005f"
|
||||||
|
@ -8126,10 +8119,10 @@ regjsparser@^0.6.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
jsesc "~0.5.0"
|
jsesc "~0.5.0"
|
||||||
|
|
||||||
remarkable@2.0.0:
|
remarkable@^2.0.1:
|
||||||
version "2.0.0"
|
version "2.0.1"
|
||||||
resolved "https://registry.yarnpkg.com/remarkable/-/remarkable-2.0.0.tgz#795f965bede8300362ce51a716edc322d9e7a4ca"
|
resolved "https://registry.yarnpkg.com/remarkable/-/remarkable-2.0.1.tgz#280ae6627384dfb13d98ee3995627ca550a12f31"
|
||||||
integrity sha512-3gvKFAgL4xmmVRKAMNm6UzDo/rO2gPVkZrWagp6AXEA4JvCcMcRx9aapYbb7AJAmLLvi/u06+EhzqoS7ha9qOg==
|
integrity sha512-YJyMcOH5lrR+kZdmB0aJJ4+93bEojRZ1HGDn9Eagu6ibg7aVZhc3OWbbShRid+Q5eAfsEqWxpe+g5W5nYNfNiA==
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse "^1.0.10"
|
argparse "^1.0.10"
|
||||||
autolinker "^3.11.0"
|
autolinker "^3.11.0"
|
||||||
|
@ -9301,11 +9294,16 @@ tryer@^1.0.1:
|
||||||
resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8"
|
resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8"
|
||||||
integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==
|
integrity sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==
|
||||||
|
|
||||||
tslib@^1.9.0, tslib@^1.9.3:
|
tslib@^1.9.0:
|
||||||
version "1.11.1"
|
version "1.11.1"
|
||||||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35"
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.11.1.tgz#eb15d128827fbee2841549e171f45ed338ac7e35"
|
||||||
integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA==
|
integrity sha512-aZW88SY8kQbU7gpV19lN24LtXh/yD4ZZg6qieAJDDg+YBsJcSmLGK9QpnUjAKVG/xefmvJGd1WUmfpT/g6AJGA==
|
||||||
|
|
||||||
|
tslib@^1.9.3:
|
||||||
|
version "1.13.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.13.0.tgz#c881e13cc7015894ed914862d276436fa9a47043"
|
||||||
|
integrity sha512-i/6DQjL8Xf3be4K/E6Wgpekn5Qasl1usyw++dAA35Ue5orEn65VIxOA+YvNNl9HV3qv70T7CNwjODHZrLwvd1Q==
|
||||||
|
|
||||||
tty-browserify@0.0.0:
|
tty-browserify@0.0.0:
|
||||||
version "0.0.0"
|
version "0.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
|
resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
|
||||||
|
|
Loading…
Reference in New Issue