Page MenuHomePhorge

No OneTemporary

Authored By
Unknown
Size
26 KB
Referenced Files
None
Subscribers
None
diff --git a/src/components/rich_content/rich_content.jsx b/src/components/rich_content/rich_content.jsx
index 60b546a83a..1625f4a512 100644
--- a/src/components/rich_content/rich_content.jsx
+++ b/src/components/rich_content/rich_content.jsx
@@ -1,499 +1,544 @@
import { flattenDeep, unescape as ldUnescape } from 'lodash'
import HashtagLink from 'src/components/hashtag_link/hashtag_link.vue'
import { MENTIONS_LIMIT } from 'src/components/mentions_line/mentions_line.js'
import MentionsLine from 'src/components/mentions_line/mentions_line.vue'
import StillImage from 'src/components/still-image/still-image.vue'
import StillImageEmojiPopover from 'src/components/still-image/still-image-emoji-popover.vue'
import { convertHtmlToLines } from 'src/services/html_converter/html_line_converter.service.js'
import { convertHtmlToTree } from 'src/services/html_converter/html_tree_converter.service.js'
import {
getAttrs,
getTagName,
processTextForEmoji,
} from 'src/services/html_converter/utility.service.js'
import './rich_content.scss'
const MAYBE_LINE_BREAKING_ELEMENTS = [
'blockquote',
'br',
'hr',
'ul',
'ol',
'li',
'p',
'table',
'tbody',
'td',
'th',
'thead',
'tr',
'h1',
'h2',
'h3',
'h4',
'h5',
]
/**
* 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 {
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,
},
// Faint style (for notifs)
faint: {
required: false,
type: Boolean,
default: false,
},
// Collapse newlines
collapse: {
required: false,
type: Boolean,
default: false,
},
/* Content comes from current instance
*
* This is used for emoji stealing popover.
* By default we assume it is, so that steal
* emoji button isn't shown where it probably
* should not be.
*/
isLocal: {
required: false,
type: Boolean,
default: true,
},
// Allow wide emoji (max 3:1 ratio)
allowNonSquareEmoji: {
required: false,
type: Boolean,
default: false,
},
},
// NEVER EVER TOUCH DATA INSIDE RENDER
render() {
// 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 {...getAttrs(tag)} class="img" />
}
const renderHashtag = (attrs, children, encounteredTextReverse) => {
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 ''
}
}
// 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
// Don't remove last space in a container (fixes poast mentions)
return index !== array.length - 1 && currentMentions !== null
? item.trim()
: item
}
currentMentions = null
if (item.includes(':')) {
item = [
'',
processTextForEmoji(item, this.emoji, ({ shortcode, url }) => {
return (
<StillImageEmojiPopover
class="emoji img"
src={url}
title={`:${shortcode}:`}
alt={`:${shortcode}:`}
shortcode={shortcode}
isLocal={this.isLocal}
/>
)
}),
]
}
return item
}
// Handle tag nodes
if (Array.isArray(item)) {
const [opener, children, closer] = item
let Tag = getTagName(opener)
if (Tag.toLowerCase() === 'script') Tag = 'js-exploit'
if (Tag.toLowerCase() === 'style') Tag = 'css-exploit'
const fullAttrs = getAttrs(opener, () => true)
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
: ''
if (MAYBE_LINE_BREAKING_ELEMENTS.includes(Tag)) {
// all the elements that can cause a line change
currentMentions = null
} else if (Tag === 'img') {
// replace images with StillImage
return ['', [mentionsLinePadding, renderImage(opener)], '']
} else if (Tag === 'a' && this.handleLinks) {
// replace mentions with MentionLink
if (fullAttrs.class && fullAttrs.class.includes('mention')) {
// Handling mentions here
return renderMention(attrs, children)
} else {
currentMentions = null
}
} else if (Tag === 'span') {
if (
this.handleLinks &&
fullAttrs.class &&
fullAttrs.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 ldUnescape(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 fullAttrs = getAttrs(opener, () => true)
const attrs = getAttrs(opener, () => true)
// should only be this
if (
(fullAttrs.class && fullAttrs.class.includes('hashtag')) || // Pleroma style
fullAttrs.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
const attrs = getAttrs(opener)
const newAttrs = { ...attrs }
const fullAttrs = getAttrs(opener, () => true)
const classname = fullAttrs['class']
const isMFM = classname?.startsWith('mfm-')
if (isMFM) {
const mfmOperator = /^mfm-(\w+)$/.exec(classname)?.[1]
newAttrs['class'] = 'mfm'
newAttrs['data-mfm-operator'] = mfmOperator
switch(mfmOperator) {
case 'position': {
- const x = fullAttrs['data-mfm-x'] || 0
- const y = fullAttrs['data-mfm-y'] || 0
+ const x = fullAttrs['data-mfm-x'].replace(/\.+$/,'') || 0
+ const y = fullAttrs['data-mfm-y'].replace(/\.+$/,'') || 0
newAttrs.style = [
'transform:',
- `translateX(calc(${x} * (var(--emoji-size) / 2)))`,
- `translateY(calc(${y} * (var(--emoji-size) / 2)))`,
+ `translate(calc(${x} * (var(--emoji-size) / 2)), `,
+ `calc(${y} * (var(--emoji-size) / 2)))`,
].join(' ')
break
}
case 'scale': {
- const x = fullAttrs['data-mfm-x'] || 1
- const y = fullAttrs['data-mfm-y'] || 1
+ const x = fullAttrs['data-mfm-x'].replace(/\.+$/,'') || 1
+ const y = fullAttrs['data-mfm-y'].replace(/\.+$/,'') || 1
newAttrs.style = [
'transform:',
`scale(${x}, ${y})`,
].join(' ')
break
}
case 'rotate': {
const deg = fullAttrs['data-mfm-deg'] || 0
newAttrs.style = [
- 'transform:',
- `rotate(${deg}deg);`,
- 'transform-origin:',
- 'center',
- ].join(' ')
+ `transform: rotate(${deg}deg)`,
+ 'transform-origin: center',
+ ].join(';')
break
}
case 'bg': {
const color = fullAttrs['data-mfm-color'] || 0
newAttrs.style = [
`background-color: #${color}`,
].join(' ')
break
}
case 'fg': {
const color = fullAttrs['data-mfm-color'] || 0
newAttrs.style = [
`color: #${color}`,
- ].join(' ')
+ ].join(';')
break
}
case 'spin': {
- const speed = fullAttrs['data-mfm-speed'] = '1s'
- const delay = fullAttrs['data-mfm-delay'] = 0
+ const speed = fullAttrs['data-mfm-speed'] || '1s'
+ const delay = fullAttrs['data-mfm-delay'] || 0
+ const left = fullAttrs['data-mfm-left'] != null
+ const alternate = fullAttrs['data-mfm-alternate'] != null
const y = fullAttrs['data-mfm-y'] != null
const x = fullAttrs['data-mfm-x'] != null
+
const anim = [
x ? 'mfm-spinX' : null,
y ? 'mfm-spinY' : null,
'mfm-spin'
].filter(a => a)[0]
- newAttrs.style = `animation: ${speed} linear ${delay} infinite normal none running ${anim}`
+
+ const direction = [
+ alternate ? 'alternate' : null,
+ left ? 'reverse' : null,
+ 'normal',
+ ].filter(a => a)[0]
+
+ newAttrs.style = [
+ `animation-name: ${anim}`,
+ `animation-duration: ${speed}`,
+ 'animation-iteration-count: infinite',
+ `animation-delay: ${delay}`,
+ `animation-direction: ${direction}`,
+ 'animation-fill-mode: none',
+ 'animation-timing-function: linear',
+ ].join(';')
break
}
case 'flip': {
newAttrs.style = 'transform: scaleX(-1)'
break
}
- case 'jump':
+ case 'border': {
+ const width = fullAttrs['data-mfm-width'] || '0'
+ const style = fullAttrs['data-mfm-style'] || 'solid'
+ const color = fullAttrs['data-mfm-color'] || 'transparent'
+ const radius = fullAttrs['data-mfm-radius'] || '0'
+ const noclip = fullAttrs['data-mfm-noclip'] || false
+
+ newAttrs.style = [
+ `border: ${width} ${style} ${color}`,
+ `border-radius: ${radius}`,
+ `overflow: ${noclip ? 'visible' : 'clip'}`
+ ].join(';')
+ break
+ }
+ case 'tada':
+ case 'jelly':
case 'twitch':
case 'shake':
- case 'bounce':
- newAttrs.style = `animation: 0.75s linear 0s infinite normal none running mfm-${mfmOperator}`
+ case 'jump':
+ case 'bounce': {
+ const speed = fullAttrs['data-mfm-speed'] || '1s'
+ const delay = fullAttrs['data-mfm-delay'] || 0
+
+ const rules = [
+ `animation-name: mfm-${mfmOperator}`,
+ `animation-duration: ${speed}`,
+ 'animation-iteration-count: infinite',
+ `animation-delay: ${delay}`,
+ 'animation-direction: normal',
+ 'animation-fill-mode: none',
+ 'animation-timing-function: linear',
+ ].join(';')
+ newAttrs.style = rules
break
+ }
default:
console.log(mfmOperator, opener)
console.log(mfmOperator)
break
}
}
return <Tag {...newAttrs}>{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', this.faint ? '-faint' : '', this.allowNonSquareEmoji ? '-allow-non-square-emoji' : '']}>
{this.collapse
? pass2.map((x) => {
if (!Array.isArray(x)) return x.replace(/\n/g, ' ')
return x.map((y) => (y.type === 'br' ? ' ' : y))
})
: 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
index 230ea8be5b..4aad49477d 100644
--- a/src/components/rich_content/rich_content.scss
+++ b/src/components/rich_content/rich_content.scss
@@ -1,399 +1,406 @@
.RichContent {
font-family: var(--font);
+ text-align: center;
.mfm {
display: inline-block;
font-size: calc(var(--emoji-size) / 2);
+
+ .emoji {
+ /* Misskey's emoji width knows no bounds */
+ /* stylelint-disable-next-line declaration-no-important */
+ max-width: unset !important;
+ }
}
&.-faint {
color: var(--text);
/* stylelint-disable declaration-no-important */
--text: var(--textFaint) !important;
--link: var(--linkFaint) !important;
--funtextGreentext: var(--funtextGreentextFaint) !important;
--funtextCyantext: var(--funtextCyantextFaint) !important;
/* stylelint-enable declaration-no-important */
a {
color: var(--linkFaint);
}
}
blockquote {
margin: 0.2em 0 0.2em 0.2em;
font-style: italic;
border-left: 0.2em solid var(--textFaint);
padding-left: 1em;
}
pre {
overflow: auto;
}
code,
samp,
kbd,
var,
pre {
font-family: var(--monoFont);
}
p {
margin: 0 0 1em;
}
p:last-child {
margin: 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;
// fix vertical alignment of stealable emoji
button {
display: inline-flex;
}
}
.emoji {
display: inline-block;
width: var(--emoji-size, 32px);
height: var(--emoji-size, 32px);
}
&.-allow-non-square-emoji {
.emoji {
width: auto;
max-width: calc(var(--emoji-size, 32px) * 3);
min-width: var(--emoji-size, 32px);
}
}
.img,
video {
max-width: 100%;
max-height: 400px;
vertical-align: middle;
object-fit: contain;
}
.greentext {
color: var(--funtextGreentext);
}
.cyantext {
color: var(--funtextCyantext);
}
}
a .RichContent {
/* stylelint-disable-next-line declaration-no-important */
color: var(--link) !important;
}
@keyframes mfm-spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
@keyframes mfm-spinX {
0% {
transform: perspective(8em) rotateX(0);
}
100% {
transform: perspective(8em) rotateX(360deg);
}
}
@keyframes mfm-spinY {
0% {
transform: perspective(8em) rotateY(0);
}
100% {
transform: perspective(8em) rotateY(360deg);
}
}
@keyframes mfm-jump {
0% {
transform: translateY(0)
}
25% {
- transform: translateY(-16px)
+ transform: translateY(-1em)
}
50% {
transform: translateY(0)
}
75% {
- transform: translateY(-8px)
+ transform: translateY(-0.5em)
}
100% {
transform: translateY(0)
}
}
@keyframes mfm-bounce {
0% {
transform: translateY(0) scale(1)
}
25% {
transform: translateY(-16px) scale(1)
}
50% {
transform: translateY(0) scale(1)
}
75% {
transform: translateY(0) scale(1.5,.75)
}
100% {
transform: translateY(0) scale(1)
}
}
@keyframes mfm-twitch {
0% {
transform: translate(7px,-2px)
}
5% {
transform: translate(-3px,1px)
}
10% {
transform: translate(-7px,-1px)
}
15% {
transform: translateY(-1px)
}
20% {
transform: translate(-8px,6px)
}
25% {
transform: translate(-4px,-3px)
}
30% {
transform: translate(-4px,-6px)
}
35% {
transform: translate(-8px,-8px)
}
40% {
transform: translate(4px,6px)
}
45% {
transform: translate(-3px,1px)
}
50% {
transform: translate(2px,-10px)
}
55% {
transform: translate(-7px)
}
60% {
transform: translate(-2px,4px)
}
65% {
transform: translate(3px,-8px)
}
70% {
transform: translate(6px,7px)
}
75% {
transform: translate(-7px,-2px)
}
80% {
transform: translate(-7px,-8px)
}
85% {
transform: translate(9px,3px)
}
90% {
transform: translate(-3px,-2px)
}
95% {
transform: translate(-10px,2px)
}
100% {
transform: translate(-2px,-6px)
}
}
@keyframes mfm-shake {
0% {
transform: translate(-3px,-1px) rotate(-8deg)
}
5% {
transform: translateY(-1px) rotate(-10deg)
}
10% {
transform: translate(1px,-3px) rotate(0)
}
15% {
transform: translate(1px,1px) rotate(11deg)
}
20% {
transform: translate(-2px,1px) rotate(1deg)
}
25% {
transform: translate(-1px,-2px) rotate(-2deg)
}
30% {
transform: translate(-1px,2px) rotate(-3deg)
}
35% {
transform: translate(2px,1px)rotate(6deg)
}
40% {
transform: translate(-2px,-3px)rotate(-9deg)
}
45% {
transform: translateY(-1px)rotate(-12deg)
}
50% {
transform: translate(1px,2px)rotate(10deg)
}
55% {
transform: translateY(-3px)rotate(8deg)
}
60% {
transform: translate(1px,-1px)rotate(8deg)
}
65% {
transform: translateY(-1px)rotate(-7deg)
}
70% {
transform: translate(-1px,-3px)rotate(6deg)
}
75% {
transform: translateY(-2px)rotate(4deg)
}
80% {
transform: translate(-2px,-1px)rotate(3deg)
}
85% {
transform: translate(1px,-3px)rotate(-10deg)
}
90% {
transform: translate(1px)rotate(3deg)
}
95% {
transform: translate(-2px)rotate(-3deg)
}
100% {
transform: translate(2px,1px)rotate(2deg)
}
}
@keyframes mfm-rubberBand {
0% {
transform: scale(1)
}
30% {
transform: scale(1.25,.75)
}
40% {
transform: scale(.75,1.25)
}
50% {
transform: scale(1.15,.85)
}
65% {
transform: scale(.95,1.05)
}
75% {
transform: scale(1.05,.95)
}
100% {
transform: scale(1)
}
}
@keyframes mfm-rainbow {
0% {
filter: hue-rotate()contrast(150%)saturate(150%)
}
100% {
filter: hue-rotate(360deg)contrast(150%)saturate(150%)
}
}

File Metadata

Mime Type
text/x-diff
Expires
Fri, Oct 30, 6:49 AM (23 h, 26 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
1799717
Default Alt Text
(26 KB)

Event Timeline