akkoma-fe/src/components/rich_content/rich_content.jsx

400 lines
13 KiB
React
Raw Normal View History

2021-06-07 13:16:10 +00:00
import { unescape, flattenDeep } from 'lodash'
import { getTagName, processTextForEmoji, getAttrs } from 'src/services/html_converter/utility.service.js'
import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
2021-06-10 15:52:01 +00:00
import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
import { marked } from 'marked'
import markedMfm from 'marked-mfm'
import StillImage from 'src/components/still-image/still-image.vue'
import MentionsLine, { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.vue'
import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue'
import './rich_content.scss'
const selectContent = (html, sourceContent, mfm) => {
if (!mfm) return html
return sourceContent === '' ? html : sourceContent
}
2021-06-12 17:42:17 +00:00
/**
* RichContent, The Über-powered component for rendering Post HTML.
*
* This takes post HTML and does multiple things to it:
* - Groups all mentions into <MentionsLine>, this affects all mentions regardles
* of where they are (beginning/middle/end), even single mentions are converted
* to a <MentionsLine> containing single <MentionLink>.
2021-06-12 17:42:17 +00:00
* - Replaces emoji shortcodes with <StillImage>'d images.
*
* There are two problems with this component's architecture:
* 1. Parsing HTML and rendering are inseparable. Attempts to separate the two
* proven to be a massive overcomplication due to amount of things done here.
* 2. We need to output both render and some extra data, which seems to be imp-
* possible in vue. Current solution is to emit 'parseReady' event when parsing
* is done within render() function.
*
* Apart from that one small hiccup with emit in render this _should_ be vue3-ready
*/
2022-03-16 20:13:21 +00:00
export default {
name: 'RichContent',
components: {
MentionsLine,
HashtagLink
},
props: {
// Original html content
html: {
required: true,
type: String
},
attentions: {
required: false,
default: () => []
},
// Emoji object, as in status.emojis, note the "s" at the end...
emoji: {
required: true,
type: Array
},
// Whether to handle links or not (posts: yes, everything else: no)
handleLinks: {
required: false,
type: Boolean,
default: false
},
// Meme arrows
greentext: {
required: false,
type: Boolean,
default: false
},
// Render Misskey Markdown
mfm: {
required: false,
type: Boolean,
default: false
},
sourceContent: {
required: false,
type: String,
default: ''
}
},
// NEVER EVER TOUCH DATA INSIDE RENDER
2022-03-16 20:13:21 +00:00
render () {
2022-07-13 17:59:41 +00:00
// Don't greentext MFM
2022-07-15 09:55:47 +00:00
const greentext = this.mfm ? false : this.greentext
2022-07-13 17:59:41 +00:00
// Pre-process HTML
const useContent = selectContent(this.html, this.sourceContent, this.mfm)
const { newHtml: html } = preProcessPerLine(useContent, greentext)
let currentMentions = null // Current chain of mentions, we group all mentions together
2021-08-18 17:54:04 +00:00
// This is used to recover spacing removed when parsing mentions
let lastSpacing = ''
const lastTags = [] // Tags that appear at the end of post body
const writtenMentions = [] // All mentions that appear in post body
const invisibleMentions = [] // All mentions that go beyond the limiter (see MentionsLine)
// to collapse too many mentions in a row
const writtenTags = [] // All tags that appear in post body
// unique index for vue "tag" property
let mentionIndex = 0
let tagsIndex = 0
const renderImage = (tag) => {
return <StillImage
{...getAttrs(tag)}
class="img"
/>
}
const renderHashtag = (attrs, children, encounteredTextReverse) => {
2022-03-22 14:40:45 +00:00
const { index, ...linkData } = getLinkData(attrs, children, tagsIndex++)
writtenTags.push(linkData)
if (!encounteredTextReverse) {
lastTags.push(linkData)
}
const { url, tag, content } = linkData
return <HashtagLink url={url} tag={tag} content={content}/>
}
const renderMention = (attrs, children) => {
const linkData = getLinkData(attrs, children, mentionIndex++)
linkData.notifying = this.attentions.some(a => a.statusnet_profile_url === linkData.url)
writtenMentions.push(linkData)
if (currentMentions === null) {
currentMentions = []
}
currentMentions.push(linkData)
if (currentMentions.length > MENTIONS_LIMIT) {
invisibleMentions.push(linkData)
}
if (currentMentions.length === 1) {
return <MentionsLine mentions={ currentMentions } />
} else {
return ''
}
2021-06-07 13:16:10 +00:00
}
const renderMisskeyMarkdown = (content) => {
// Untangle code blocks from <br> tags and other html encodings
2022-08-01 13:13:14 +00:00
const codeblocks = content.match(/(<br\/>)?(~~~|```)\w*<br\/>.+?<br\/>\2\1?/g)
if (codeblocks) {
codeblocks.forEach((pre) => {
content = content.replace(pre,
pre.replaceAll('<br/>', '\n')
.replaceAll('&amp;', '&')
.replaceAll('&lt;', '<')
.replaceAll('&gt;', '>')
.replaceAll('&quot', '"')
.replaceAll('&#39;', "'")
)
2022-08-01 13:13:14 +00:00
})
}
marked.use(markedMfm, {
mangle: false,
gfm: false,
breaks: true
})
2022-07-10 14:38:35 +00:00
const mfmHtml = document.createElement('template')
mfmHtml.innerHTML = marked.parse(content)
// Add options with set values to CSS
if (mfmHtml.content.firstChild) {
Array.from(mfmHtml.content.firstChild.getElementsByClassName('mfm')).map((el) => {
if (el.dataset.speed) {
el.style.animationDuration = el.dataset.speed
2022-07-10 14:38:35 +00:00
}
if (el.dataset.deg) {
el.style.transform = `rotate(${el.dataset.deg}deg)`
}
if (Array.from(el.classList).includes('_mfm_font_')) {
const font = Object.keys(el.dataset)[0]
if (['serif', 'monospace', 'cursive', 'fantasy', 'emoji', 'math'].includes(font)) {
el.style.fontFamily = font
}
}
})
}
2022-07-10 14:38:35 +00:00
return mfmHtml.innerHTML
}
// Processor to use with html_tree_converter
const processItem = (item, index, array) => {
// Handle text nodes - just add emoji
if (typeof item === 'string') {
2021-06-08 10:42:16 +00:00
const emptyText = item.trim() === ''
if (item.includes('\n')) {
currentMentions = null
2021-06-08 08:38:44 +00:00
}
if (emptyText) {
// don't include spaces when processing mentions - we'll include them
// in MentionsLine
2021-08-18 17:54:04 +00:00
lastSpacing = item
2022-02-03 20:23:28 +00:00
// Don't remove last space in a container (fixes poast mentions)
return (index !== array.length - 1) && (currentMentions !== null) ? item.trim() : item
2021-06-08 08:38:44 +00:00
}
2021-08-15 15:11:38 +00:00
currentMentions = null
if (item.includes(':')) {
2021-06-18 18:42:46 +00:00
item = ['', processTextForEmoji(
item,
this.emoji,
({ shortcode, url }) => {
return <StillImage
2021-06-07 09:49:54 +00:00
class="emoji img"
src={url}
title={`:${shortcode}:`}
alt={`:${shortcode}:`}
/>
}
2021-06-15 11:43:44 +00:00
)]
}
2021-06-18 18:42:46 +00:00
return item
}
// Handle tag nodes
if (Array.isArray(item)) {
2021-06-15 11:43:44 +00:00
const [opener, children, closer] = item
const Tag = getTagName(opener)
const attrs = getAttrs(opener)
2021-08-18 17:54:04 +00:00
const previouslyMentions = currentMentions !== null
/* During grouping of mentions we trim all the empty text elements
* This padding is added to recover last space removed in case
* we have a tag right next to mentions
*/
const mentionsLinePadding =
// Padding is only needed if we just finished parsing mentions
previouslyMentions &&
// Don't add padding if content is string and has padding already
!(children && typeof children[0] === 'string' && children[0].match(/^\s/))
? lastSpacing
: ''
2021-06-07 13:16:10 +00:00
switch (Tag) {
case 'br':
currentMentions = null
break
case 'img': // replace images with StillImage
2021-08-18 17:54:04 +00:00
return ['', [mentionsLinePadding, renderImage(opener)], '']
case 'a': // replace mentions with MentionLink
if (!this.handleLinks) break
2021-06-07 13:16:10 +00:00
if (attrs['class'] && attrs['class'].includes('mention')) {
2021-06-15 22:20:20 +00:00
// Handling mentions here
return renderMention(attrs, children)
2021-06-15 22:20:20 +00:00
} else {
currentMentions = null
2021-08-18 17:54:04 +00:00
break
2021-06-07 13:16:10 +00:00
}
case 'span':
if (this.handleLinks && attrs['class'] && attrs['class'].includes('h-card')) {
return ['', children.map(processItem), '']
}
}
2021-06-15 22:20:20 +00:00
if (children !== undefined) {
2021-08-18 17:54:04 +00:00
return [
2021-08-23 18:36:18 +00:00
'',
[
mentionsLinePadding,
[opener, children.map(processItem), closer]
],
''
2021-08-18 17:54:04 +00:00
]
} else {
2021-08-18 17:54:04 +00:00
return ['', [mentionsLinePadding, item], '']
}
}
}
2021-06-10 15:52:01 +00:00
// Processor for back direction (for finding "last" stuff, just easier this way)
let encounteredTextReverse = false
const processItemReverse = (item, index, array, what) => {
// Handle text nodes - just add emoji
if (typeof item === 'string') {
const emptyText = item.trim() === ''
2021-06-12 14:20:21 +00:00
if (emptyText) return item
2021-06-10 15:52:01 +00:00
if (!encounteredTextReverse) encounteredTextReverse = true
2021-06-18 18:42:46 +00:00
return unescape(item)
2021-06-10 15:52:01 +00:00
} else if (Array.isArray(item)) {
// Handle tag nodes
const [opener, children] = item
2021-06-15 11:43:44 +00:00
const Tag = opener === '' ? '' : getTagName(opener)
2021-06-10 15:52:01 +00:00
switch (Tag) {
case 'a': // replace mentions with MentionLink
if (!this.handleLinks) break
const attrs = getAttrs(opener)
// should only be this
if (
(attrs['class'] && attrs['class'].includes('hashtag')) || // Pleroma style
(attrs['rel'] === 'tag') // Mastodon style
) {
2021-06-10 15:52:01 +00:00
return renderHashtag(attrs, children, encounteredTextReverse)
2021-06-15 22:20:20 +00:00
} else {
attrs.target = '_blank'
const newChildren = [...children].reverse().map(processItemReverse).reverse()
return <a {...attrs}>
2021-06-15 22:20:20 +00:00
{ newChildren }
</a>
2021-06-10 15:52:01 +00:00
}
2021-06-15 11:43:44 +00:00
case '':
return [...children].reverse().map(processItemReverse).reverse()
}
// Render tag as is
if (children !== undefined) {
2021-06-15 22:20:20 +00:00
const newChildren = Array.isArray(children)
? [...children].reverse().map(processItemReverse).reverse()
: children
2022-03-22 14:40:45 +00:00
return <Tag {...getAttrs(opener)}>
2021-06-15 22:20:20 +00:00
{ newChildren }
2021-06-15 11:43:44 +00:00
</Tag>
} else {
return <Tag/>
2021-06-10 15:52:01 +00:00
}
}
return item
}
const pass1 = convertHtmlToTree(this.mfm ? renderMisskeyMarkdown(html) : html).map(processItem)
2021-06-15 11:43:44 +00:00
const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
2021-06-11 08:05:28 +00:00
// DO NOT USE SLOTS they cause a re-render feedback loop here.
// slots updated -> rerender -> emit -> update up the tree -> rerender -> ...
// at least until vue3?
const result = <span class="RichContent">
2021-06-15 11:43:44 +00:00
{ pass2 }
2021-06-11 08:05:28 +00:00
</span>
const event = {
lastTags,
writtenMentions,
writtenTags,
invisibleMentions
}
// DO NOT MOVE TO UPDATE. BAD IDEA.
this.$emit('parseReady', event)
return result
}
2022-03-16 20:13:21 +00:00
}
const getLinkData = (attrs, children, index) => {
const stripTags = (item) => {
if (typeof item === 'string') {
return item
} else {
return item[1].map(stripTags).join('')
}
}
const textContent = children.map(stripTags).join('')
return {
index,
url: attrs.href,
tag: attrs['data-tag'],
content: flattenDeep(children).join(''),
textContent
}
}
2021-06-10 15:52:01 +00:00
/** Pre-processing HTML
*
* Currently this does one thing:
2021-06-10 15:52:01 +00:00
* - add green/cyantexting
*
* @param {String} html - raw HTML to process
* @param {Boolean} greentext - whether to enable greentexting or not
*/
export const preProcessPerLine = (html, greentext) => {
const greentextHandle = new Set(['p', 'div'])
2021-06-10 15:52:01 +00:00
2021-06-13 19:22:59 +00:00
const lines = convertHtmlToLines(html)
const newHtml = lines.reverse().map((item, index, array) => {
2021-06-10 15:52:01 +00:00
if (!item.text) return item
const string = item.text
// Greentext stuff
if (
// Only if greentext is engaged
greentext &&
// Only handle p's and divs. Don't want to affect blockquotes, code etc
item.level.every(l => greentextHandle.has(l)) &&
// Only if line begins with '>' or '<'
(string.includes('&gt;') || string.includes('&lt;'))
) {
2021-06-10 15:52:01 +00:00
const cleanedString = string.replace(/<[^>]+?>/gi, '') // remove all tags
.replace(/@\w+/gi, '') // remove mentions (even failed ones)
.trim()
if (cleanedString.startsWith('&gt;')) {
return `<span class='greentext'>${string}</span>`
} else if (cleanedString.startsWith('&lt;')) {
return `<span class='cyantext'>${string}</span>`
}
}
return string
2021-06-10 15:52:01 +00:00
}).reverse().join('')
return { newHtml }
}