aboutsummaryrefslogtreecommitdiff
path: root/src/components/rich_content
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/rich_content')
-rw-r--r--src/components/rich_content/rich_content.jsx327
-rw-r--r--src/components/rich_content/rich_content.scss64
2 files changed, 391 insertions, 0 deletions
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
new file mode 100644
index 00000000..c0d20c5e
--- /dev/null
+++ b/src/components/rich_content/rich_content.jsx
@@ -0,0 +1,327 @@
+import Vue from 'vue'
+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'
+import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
+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'
+
+/**
+ * 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>.
+ * - 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
+ */
+export default Vue.component('RichContent', {
+ name: 'RichContent',
+ 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
+ }
+ },
+ // NEVER EVER TOUCH DATA INSIDE RENDER
+ render (h) {
+ // Pre-process HTML
+ const { newHtml: html } = preProcessPerLine(this.html, this.greentext)
+ let currentMentions = null // Current chain of mentions, we group all mentions together
+ // 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
+ {...{ attrs: getAttrs(tag) }}
+ class="img"
+ />
+ }
+
+ const renderHashtag = (attrs, children, encounteredTextReverse) => {
+ const linkData = getLinkData(attrs, children, tagsIndex++)
+ writtenTags.push(linkData)
+ if (!encounteredTextReverse) {
+ lastTags.push(linkData)
+ }
+ return <HashtagLink {...{ props: linkData }}/>
+ }
+
+ 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 ''
+ }
+ }
+
+ // Processor to use with html_tree_converter
+ const processItem = (item, index, array, what) => {
+ // Handle text nodes - just add emoji
+ if (typeof item === 'string') {
+ const emptyText = item.trim() === ''
+ if (item.includes('\n')) {
+ currentMentions = null
+ }
+ if (emptyText) {
+ // don't include spaces when processing mentions - we'll include them
+ // in MentionsLine
+ lastSpacing = item
+ return currentMentions !== null ? item.trim() : item
+ }
+
+ currentMentions = null
+ if (item.includes(':')) {
+ item = ['', processTextForEmoji(
+ item,
+ this.emoji,
+ ({ shortcode, url }) => {
+ return <StillImage
+ class="emoji img"
+ src={url}
+ title={`:${shortcode}:`}
+ alt={`:${shortcode}:`}
+ />
+ }
+ )]
+ }
+ return item
+ }
+
+ // Handle tag nodes
+ if (Array.isArray(item)) {
+ const [opener, children, closer] = item
+ const Tag = getTagName(opener)
+ const attrs = getAttrs(opener)
+ 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
+ : ''
+ switch (Tag) {
+ case 'br':
+ currentMentions = null
+ break
+ case 'img': // replace images with StillImage
+ return ['', [mentionsLinePadding, renderImage(opener)], '']
+ case 'a': // replace mentions with MentionLink
+ if (!this.handleLinks) break
+ if (attrs['class'] && attrs['class'].includes('mention')) {
+ // Handling mentions here
+ return renderMention(attrs, children)
+ } else {
+ currentMentions = null
+ break
+ }
+ case 'span':
+ if (this.handleLinks && attrs['class'] && attrs['class'].includes('h-card')) {
+ return ['', children.map(processItem), '']
+ }
+ }
+
+ if (children !== undefined) {
+ return [
+ '',
+ [
+ mentionsLinePadding,
+ [opener, children.map(processItem), closer]
+ ],
+ ''
+ ]
+ } else {
+ return ['', [mentionsLinePadding, item], '']
+ }
+ }
+ }
+
+ // 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() === ''
+ if (emptyText) return item
+ if (!encounteredTextReverse) encounteredTextReverse = true
+ return unescape(item)
+ } else if (Array.isArray(item)) {
+ // Handle tag nodes
+ const [opener, children] = item
+ const Tag = opener === '' ? '' : getTagName(opener)
+ 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
+ ) {
+ return renderHashtag(attrs, children, encounteredTextReverse)
+ } else {
+ attrs.target = '_blank'
+ const newChildren = [...children].reverse().map(processItemReverse).reverse()
+
+ return <a {...{ attrs }}>
+ { newChildren }
+ </a>
+ }
+ case '':
+ return [...children].reverse().map(processItemReverse).reverse()
+ }
+
+ // Render tag as is
+ if (children !== undefined) {
+ const newChildren = Array.isArray(children)
+ ? [...children].reverse().map(processItemReverse).reverse()
+ : children
+ return <Tag {...{ attrs: getAttrs(opener) }}>
+ { newChildren }
+ </Tag>
+ } else {
+ return <Tag/>
+ }
+ }
+ return item
+ }
+
+ const pass1 = convertHtmlToTree(html).map(processItem)
+ const pass2 = [...pass1].reverse().map(processItemReverse).reverse()
+ // 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">
+ { pass2 }
+ </span>
+
+ const event = {
+ lastTags,
+ writtenMentions,
+ writtenTags,
+ invisibleMentions
+ }
+
+ // DO NOT MOVE TO UPDATE. BAD IDEA.
+ this.$emit('parseReady', event)
+
+ return result
+ }
+})
+
+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
+ }
+}
+
+/** Pre-processing HTML
+ *
+ * Currently this does one thing:
+ * - 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'])
+
+ const lines = convertHtmlToLines(html)
+ const newHtml = lines.reverse().map((item, index, array) => {
+ 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;'))
+ ) {
+ 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
+ }).reverse().join('')
+
+ return { newHtml }
+}
diff --git a/src/components/rich_content/rich_content.scss b/src/components/rich_content/rich_content.scss
new file mode 100644
index 00000000..db08ef1e
--- /dev/null
+++ b/src/components/rich_content/rich_content.scss
@@ -0,0 +1,64 @@
+.RichContent {
+ blockquote {
+ margin: 0.2em 0 0.2em 2em;
+ font-style: italic;
+ }
+
+ pre {
+ overflow: auto;
+ }
+
+ code,
+ samp,
+ kbd,
+ var,
+ pre {
+ font-family: var(--postCodeFont, monospace);
+ }
+
+ p {
+ margin: 0 0 1em 0;
+ }
+
+ p:last-child {
+ margin: 0 0 0 0;
+ }
+
+ h1 {
+ font-size: 1.1em;
+ line-height: 1.2em;
+ margin: 1.4em 0;
+ }
+
+ h2 {
+ font-size: 1.1em;
+ margin: 1em 0;
+ }
+
+ h3 {
+ font-size: 1em;
+ margin: 1.2em 0;
+ }
+
+ h4 {
+ margin: 1.1em 0;
+ }
+
+ .img {
+ display: inline-block;
+ }
+
+ .emoji {
+ display: inline-block;
+ width: var(--emoji-size, 32px);
+ height: var(--emoji-size, 32px);
+ }
+
+ .img,
+ video {
+ max-width: 100%;
+ max-height: 400px;
+ vertical-align: middle;
+ object-fit: contain;
+ }
+}