mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-13 02:42:30 +00:00
add mfm support
This commit is contained in:
@@ -17,7 +17,7 @@
|
|||||||
import { isoDate, longDate, stampDate } from '$lib/util/time'
|
import { isoDate, longDate, stampDate } from '$lib/util/time'
|
||||||
import { extractYouTubeVideoIds } from '$lib/util/youtube'
|
import { extractYouTubeVideoIds } from '$lib/util/youtube'
|
||||||
import Avatar from '../common/Avatar.svelte'
|
import Avatar from '../common/Avatar.svelte'
|
||||||
import RichText from '../common/RichText.svelte'
|
import MfmContent from '../common/MfmContent.svelte'
|
||||||
import Attachments from './Attachments.svelte'
|
import Attachments from './Attachments.svelte'
|
||||||
import PollView from './PollView.svelte'
|
import PollView from './PollView.svelte'
|
||||||
import PreviewCardView from './PreviewCardView.svelte'
|
import PreviewCardView from './PreviewCardView.svelte'
|
||||||
@@ -181,7 +181,7 @@
|
|||||||
{#if entry.spoiler_text}
|
{#if entry.spoiler_text}
|
||||||
<details class="content-warning">
|
<details class="content-warning">
|
||||||
<summary class="content-warning-summary">{entry.spoiler_text}</summary>
|
<summary class="content-warning-summary">{entry.spoiler_text}</summary>
|
||||||
<RichText
|
<MfmContent
|
||||||
html={entry.content}
|
html={entry.content}
|
||||||
emojis={entry.emojis}
|
emojis={entry.emojis}
|
||||||
mentions={entry.mentions}
|
mentions={entry.mentions}
|
||||||
@@ -194,7 +194,7 @@
|
|||||||
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
|
<YouTubeEmbeds videoIds={youtubeVideoIds} sensitive={entry.sensitive} />
|
||||||
</details>
|
</details>
|
||||||
{:else}
|
{:else}
|
||||||
<RichText
|
<MfmContent
|
||||||
html={entry.content}
|
html={entry.content}
|
||||||
emojis={entry.emojis}
|
emojis={entry.emojis}
|
||||||
mentions={entry.mentions}
|
mentions={entry.mentions}
|
||||||
|
|||||||
@@ -48,3 +48,21 @@ describe('BlogEntry YouTube embeds', () => {
|
|||||||
expect(view.getByTitle('YouTube video')).toBeInTheDocument()
|
expect(view.getByTitle('YouTube video')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('BlogEntry MFM rendering', () => {
|
||||||
|
it('renders the Pleroma API MFM HTML inside a backend-free blog entry', () => {
|
||||||
|
const view = renderEntry({
|
||||||
|
content:
|
||||||
|
'<p>It <span class="mfm-spin" data-mfm-speed="2s" data-mfm-left>works</span></p>',
|
||||||
|
})
|
||||||
|
|
||||||
|
const effect = view.getByText('works')
|
||||||
|
expect(effect).toHaveClass('mfm', '-pause')
|
||||||
|
expect(effect).toHaveAttribute('data-mfm-operator', 'spin')
|
||||||
|
expect(effect).toHaveStyle({
|
||||||
|
animationName: 'mfm-spin',
|
||||||
|
animationDuration: '2s',
|
||||||
|
animationDirection: 'reverse',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Status HTML renderer with Pleroma-FE-compatible MFM effects.
|
||||||
|
*
|
||||||
|
* This consumes the server-rendered FEP-c16b spans found in `status.content`;
|
||||||
|
* it intentionally does not parse raw `$[...]` source syntax.
|
||||||
|
*/
|
||||||
|
import type { CustomEmoji, StatusMention, StatusTag } from '$lib/api/types'
|
||||||
|
import { renderMfmHtml } from '$lib/util/mfm'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
html: string | null | undefined
|
||||||
|
emojis?: CustomEmoji[]
|
||||||
|
mentions?: StatusMention[]
|
||||||
|
tags?: StatusTag[]
|
||||||
|
inline?: boolean
|
||||||
|
class?: string
|
||||||
|
lang?: string | null
|
||||||
|
/** Pleroma-FE defaults to pausing animation until hover. */
|
||||||
|
pause?: boolean
|
||||||
|
scale?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
html,
|
||||||
|
emojis,
|
||||||
|
mentions,
|
||||||
|
tags,
|
||||||
|
inline = false,
|
||||||
|
class: extraClass = '',
|
||||||
|
lang,
|
||||||
|
pause = true,
|
||||||
|
scale = false,
|
||||||
|
}: Props = $props()
|
||||||
|
|
||||||
|
const rendered = $derived(
|
||||||
|
renderMfmHtml(html, { emojis, mentions, tags, inline, pause, scale }),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if rendered}
|
||||||
|
<div
|
||||||
|
class="rich-text {extraClass}"
|
||||||
|
class:rich-text--inline={inline}
|
||||||
|
lang={lang ?? undefined}
|
||||||
|
dir="auto"
|
||||||
|
>
|
||||||
|
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized in renderMfmHtml -->
|
||||||
|
{@html rendered}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { render } from '@testing-library/svelte'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import MfmContent from './MfmContent.svelte'
|
||||||
|
|
||||||
|
function mfm(view: { getByText(text: string): HTMLElement }, text: string): HTMLElement {
|
||||||
|
const element = view.getByText(text)
|
||||||
|
expect(element).toHaveClass('mfm')
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MfmContent', () => {
|
||||||
|
it('renders Pleroma server-produced MFM with its paused-until-hover default', () => {
|
||||||
|
const view = render(MfmContent, {
|
||||||
|
props: {
|
||||||
|
html: '<p>Hello <span class="mfm-spin" data-mfm-speed="0.5s" data-mfm-alternate>world</span></p>',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const element = mfm(view, 'world')
|
||||||
|
expect(element).toHaveClass('-pause')
|
||||||
|
expect(element).toHaveAttribute('data-mfm-operator', 'spin')
|
||||||
|
expect(element.style.animationName).toBe('mfm-spin')
|
||||||
|
expect(element.style.animationDuration).toBe('0.5s')
|
||||||
|
expect(element.style.animationDirection).toBe('alternate')
|
||||||
|
expect(element.style.animationIterationCount).toBe('infinite')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('supports Pleroma-FE pause and scale options without a backend', () => {
|
||||||
|
const view = render(MfmContent, {
|
||||||
|
props: {
|
||||||
|
html: '<span class="mfm-x2">large</span>',
|
||||||
|
pause: false,
|
||||||
|
scale: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const element = mfm(view, 'large')
|
||||||
|
expect(element).not.toHaveClass('-pause')
|
||||||
|
expect(element).toHaveClass('-scale')
|
||||||
|
expect(element).toHaveAttribute('data-mfm-operator', 'x2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves RichText sanitizing, custom emoji, and local link rewriting', () => {
|
||||||
|
const view = render(MfmContent, {
|
||||||
|
props: {
|
||||||
|
html: '<script>bad()</script><p><a href="https://example.test/@alice">@alice</a> :party:</p>',
|
||||||
|
mentions: [
|
||||||
|
{
|
||||||
|
id: 'account-1',
|
||||||
|
username: 'alice',
|
||||||
|
acct: 'alice',
|
||||||
|
url: 'https://example.test/@alice',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
emojis: [
|
||||||
|
{
|
||||||
|
shortcode: 'party',
|
||||||
|
url: 'https://example.test/emoji/party.png',
|
||||||
|
static_url: 'https://example.test/emoji/party.png',
|
||||||
|
visible_in_picker: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(view.container.querySelector('script')).not.toBeInTheDocument()
|
||||||
|
expect(view.getByRole('link', { name: '@alice' })).toHaveAttribute('href', '#/@alice')
|
||||||
|
expect(view.getByRole('img', { name: ':party:' })).toHaveAttribute(
|
||||||
|
'src',
|
||||||
|
'https://example.test/emoji/party.png',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
+25
-1
@@ -41,7 +41,31 @@ const ALLOWED_TAGS = [
|
|||||||
'img',
|
'img',
|
||||||
]
|
]
|
||||||
|
|
||||||
const ALLOWED_ATTR = ['href', 'rel', 'class', 'title', 'lang', 'src', 'alt', 'draggable', 'data-plspace-to']
|
const ALLOWED_ATTR = [
|
||||||
|
'href',
|
||||||
|
'rel',
|
||||||
|
'class',
|
||||||
|
'title',
|
||||||
|
'lang',
|
||||||
|
'src',
|
||||||
|
'alt',
|
||||||
|
'draggable',
|
||||||
|
'data-plspace-to',
|
||||||
|
// FEP-c16b HTML emitted by Pleroma's MFM parser. These are inert here;
|
||||||
|
// MfmContent validates and interprets them after this sanitization pass.
|
||||||
|
'data-mfm-x',
|
||||||
|
'data-mfm-y',
|
||||||
|
'data-mfm-deg',
|
||||||
|
'data-mfm-color',
|
||||||
|
'data-mfm-speed',
|
||||||
|
'data-mfm-delay',
|
||||||
|
'data-mfm-left',
|
||||||
|
'data-mfm-alternate',
|
||||||
|
'data-mfm-width',
|
||||||
|
'data-mfm-style',
|
||||||
|
'data-mfm-radius',
|
||||||
|
'data-mfm-noclip',
|
||||||
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rewrite outbound anchors:
|
* Rewrite outbound anchors:
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { renderMfmHtml } from './mfm'
|
||||||
|
|
||||||
|
function renderedElement(source: string): HTMLElement {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
container.innerHTML = renderMfmHtml(source)
|
||||||
|
const element = container.firstElementChild
|
||||||
|
if (!(element instanceof HTMLElement)) throw new Error('Expected one rendered element')
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Pleroma-FE MFM compatibility', () => {
|
||||||
|
it.each([
|
||||||
|
['position', 'data-mfm-x="2" data-mfm-y="-1"', 'translate(calc(2 * (var(--emoji-size) / 2)), calc(-1 * (var(--emoji-size) / 2)))'],
|
||||||
|
['scale', 'data-mfm-x="2" data-mfm-y="3"', 'scale(2, 3)'],
|
||||||
|
['rotate', 'data-mfm-deg="45"', 'rotate(45deg)'],
|
||||||
|
['flip', '', 'scaleX(-1)'],
|
||||||
|
])('renders the %s transform', (operator, attributes, expected) => {
|
||||||
|
const element = renderedElement(`<span class="mfm-${operator}" ${attributes}>effect</span>`)
|
||||||
|
expect(element.dataset.mfmOperator).toBe(operator)
|
||||||
|
expect(element.style.transform).toBe(expected)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders foreground and background colors', () => {
|
||||||
|
const fg = renderedElement('<span class="mfm-fg" data-mfm-color="12abEF">fg</span>')
|
||||||
|
const bg = renderedElement('<span class="mfm-bg" data-mfm-color="fed">bg</span>')
|
||||||
|
expect(fg.style.color).toBe('rgb(18, 171, 239)')
|
||||||
|
expect(bg.style.backgroundColor).toBe('rgb(255, 238, 221)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['tada', 'jelly', 'twitch', 'shake', 'jump', 'bounce', 'rainbow'])(
|
||||||
|
'renders the %s looping animation',
|
||||||
|
(operator) => {
|
||||||
|
const element = renderedElement(
|
||||||
|
`<span class="mfm-${operator}" data-mfm-speed="2s" data-mfm-delay="100ms">effect</span>`,
|
||||||
|
)
|
||||||
|
expect(element.style.animationName).toBe(`mfm-${operator}`)
|
||||||
|
expect(element.style.animationDuration).toBe('2s')
|
||||||
|
expect(element.style.animationDelay).toBe('100ms')
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('chooses spin axis and direction with Pleroma-FE precedence', () => {
|
||||||
|
const element = renderedElement(
|
||||||
|
'<span class="mfm-spin" data-mfm-x data-mfm-y data-mfm-left data-mfm-alternate>spin</span>',
|
||||||
|
)
|
||||||
|
expect(element.style.animationName).toBe('mfm-spinX')
|
||||||
|
expect(element.style.animationDirection).toBe('alternate')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders border attributes and noclip', () => {
|
||||||
|
const element = renderedElement(
|
||||||
|
'<span class="mfm-border" data-mfm-width="2px" data-mfm-style="dashed" data-mfm-color="#123456" data-mfm-radius="0.5em" data-mfm-noclip>box</span>',
|
||||||
|
)
|
||||||
|
expect(element.style.border).toBe('2px dashed rgb(18, 52, 86)')
|
||||||
|
expect(element.style.borderRadius).toBe('0.5em')
|
||||||
|
expect(element.style.overflow).toBe('visible')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['sparkle', 'x2', 'x3', 'x4'])(
|
||||||
|
'leaves the %s operator for the matching Pleroma-FE CSS rule',
|
||||||
|
(operator) => {
|
||||||
|
const element = renderedElement(`<span class="mfm-${operator}">effect</span>`)
|
||||||
|
expect(element).toHaveClass('mfm', '-pause')
|
||||||
|
expect(element.dataset.mfmOperator).toBe(operator)
|
||||||
|
expect(element.getAttribute('style')).toBeNull()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
it('supports nested server-rendered MFM spans', () => {
|
||||||
|
const container = document.createElement('div')
|
||||||
|
container.innerHTML = renderMfmHtml(
|
||||||
|
'<span class="mfm-x2">outer <span class="mfm-spin">inner</span></span>',
|
||||||
|
)
|
||||||
|
expect(container.querySelectorAll('.mfm')).toHaveLength(2)
|
||||||
|
expect(container.querySelector('[data-mfm-operator="spin"]')).toHaveStyle({
|
||||||
|
animationName: 'mfm-spin',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not interpret raw MFM source—the Pleroma backend owns parsing', () => {
|
||||||
|
const html = renderMfmHtml('$[spin.speed=1s plain source]')
|
||||||
|
expect(html).toBe('$[spin.speed=1s plain source]')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects CSS injection in FEP attributes while keeping the effect', () => {
|
||||||
|
const element = renderedElement(
|
||||||
|
'<span class="mfm-spin" data-mfm-speed="1s;color:red" data-mfm-delay="0;display:none">safe</span>',
|
||||||
|
)
|
||||||
|
expect(element.style.animationDuration).toBe('1s')
|
||||||
|
expect(element.getAttribute('style')).toContain('animation-delay: 0')
|
||||||
|
expect(element.style.color).toBe('')
|
||||||
|
expect(element.style.display).toBe('')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('drops source style and event handlers before adding trusted effect styles', () => {
|
||||||
|
const element = renderedElement(
|
||||||
|
'<span class="mfm-flip" style="position:fixed" onclick="alert(1)">safe</span>',
|
||||||
|
)
|
||||||
|
expect(element).not.toHaveAttribute('onclick')
|
||||||
|
expect(element.style.position).toBe('')
|
||||||
|
expect(element.style.transform).toBe('scaleX(-1)')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
/**
|
||||||
|
* Pleroma-FE-compatible rendering for FEP-c16b HTML.
|
||||||
|
*
|
||||||
|
* Pleroma parses raw `$[operator.attributes content]` MFM on the server and
|
||||||
|
* exposes HTML spans with `mfm-*` classes and `data-mfm-*` attributes through
|
||||||
|
* the Mastodon API. This mirrors Pleroma-FE's RichContent transformation rather
|
||||||
|
* than attempting to parse raw MFM a second time in the browser.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { RenderOptions } from './html'
|
||||||
|
import { renderHtml } from './html'
|
||||||
|
|
||||||
|
export interface MfmRenderOptions extends RenderOptions {
|
||||||
|
/** Match Pleroma-FE's default: animations resume when RichText is hovered. */
|
||||||
|
pause?: boolean
|
||||||
|
/** Scale effects against the surrounding custom-emoji size. */
|
||||||
|
scale?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOOPING_OPERATORS = new Set([
|
||||||
|
'tada',
|
||||||
|
'jelly',
|
||||||
|
'twitch',
|
||||||
|
'shake',
|
||||||
|
'jump',
|
||||||
|
'bounce',
|
||||||
|
'rainbow',
|
||||||
|
])
|
||||||
|
|
||||||
|
function numberAttribute(element: Element, name: string, fallback: number): number {
|
||||||
|
const parsed = Number.parseFloat(element.getAttribute(name) ?? '')
|
||||||
|
return Number.isFinite(parsed) && parsed !== 0 ? parsed : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAttribute(element: Element, name: string, fallback: string): string {
|
||||||
|
const value = element.getAttribute(name)?.trim()
|
||||||
|
return value && /^(?:-?(?:\d+(?:\.\d+)?|\.\d+)(?:ms|s)|0)$/i.test(value)
|
||||||
|
? value
|
||||||
|
: fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function hexAttribute(element: Element, name: string, fallback: string): string {
|
||||||
|
const value = element.getAttribute(name)?.trim()
|
||||||
|
return value && /^[0-9a-f]{1,8}$/i.test(value) ? value : fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function lengthAttribute(element: Element, name: string, fallback: string): string {
|
||||||
|
const value = element.getAttribute(name)?.trim()
|
||||||
|
return value &&
|
||||||
|
/^(?:0|-?(?:\d+(?:\.\d+)?|\.\d+)(?:px|em|rem|ex|ch|%|vw|vh|vmin|vmax)?)$/i.test(value)
|
||||||
|
? value
|
||||||
|
: fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function borderColorAttribute(element: Element): string {
|
||||||
|
const value = element.getAttribute('data-mfm-color')?.trim()
|
||||||
|
if (!value) return 'transparent'
|
||||||
|
if (/^#?[0-9a-f]{3,8}$/i.test(value) || /^(?:transparent|currentcolor)$/i.test(value)) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return 'transparent'
|
||||||
|
}
|
||||||
|
|
||||||
|
function borderStyleAttribute(element: Element): string {
|
||||||
|
const value = element.getAttribute('data-mfm-style')?.trim().toLowerCase()
|
||||||
|
return value &&
|
||||||
|
/^(?:none|hidden|dotted|dashed|solid|double|groove|ridge|inset|outset)$/.test(value)
|
||||||
|
? value
|
||||||
|
: 'solid'
|
||||||
|
}
|
||||||
|
|
||||||
|
function animationRules(name: string, speed: string, delay: string, direction = 'normal'): string {
|
||||||
|
return [
|
||||||
|
`animation-name: ${name}`,
|
||||||
|
`animation-duration: ${speed}`,
|
||||||
|
'animation-iteration-count: infinite',
|
||||||
|
`animation-delay: ${delay}`,
|
||||||
|
`animation-direction: ${direction}`,
|
||||||
|
'animation-fill-mode: none',
|
||||||
|
'animation-timing-function: linear',
|
||||||
|
].join(';')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Enhance already-sanitized FEP-c16b spans using Pleroma-FE's operator rules. */
|
||||||
|
export function enhanceMfmHtml(html: string, options: Pick<MfmRenderOptions, 'pause' | 'scale'> = {}): string {
|
||||||
|
if (!html || !html.includes('mfm-')) return html
|
||||||
|
|
||||||
|
const container = document.createElement('div')
|
||||||
|
container.innerHTML = html
|
||||||
|
|
||||||
|
for (const element of Array.from(container.querySelectorAll('[class^="mfm-"]'))) {
|
||||||
|
const originalClass = element.getAttribute('class') ?? ''
|
||||||
|
const operator = /^mfm-(\w+)$/.exec(originalClass)?.[1]
|
||||||
|
if (!operator) continue
|
||||||
|
|
||||||
|
element.setAttribute(
|
||||||
|
'class',
|
||||||
|
['mfm', options.pause !== false ? '-pause' : '', options.scale ? '-scale' : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' '),
|
||||||
|
)
|
||||||
|
element.setAttribute('data-mfm-operator', operator)
|
||||||
|
|
||||||
|
let style = ''
|
||||||
|
switch (operator) {
|
||||||
|
case 'position': {
|
||||||
|
const x = numberAttribute(element, 'data-mfm-x', 0)
|
||||||
|
const y = numberAttribute(element, 'data-mfm-y', 0)
|
||||||
|
style = `transform: translate(calc(${x} * (var(--emoji-size) / 2)), calc(${y} * (var(--emoji-size) / 2)))`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'scale': {
|
||||||
|
const x = numberAttribute(element, 'data-mfm-x', 1)
|
||||||
|
const y = numberAttribute(element, 'data-mfm-y', 1)
|
||||||
|
style = `transform: scale(${x}, ${y})`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'rotate': {
|
||||||
|
const degrees = numberAttribute(element, 'data-mfm-deg', 0)
|
||||||
|
style = `transform: rotate(${degrees}deg);transform-origin: center`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'bg':
|
||||||
|
style = `background-color: #${hexAttribute(element, 'data-mfm-color', '0')}`
|
||||||
|
break
|
||||||
|
case 'fg':
|
||||||
|
style = `color: #${hexAttribute(element, 'data-mfm-color', '0')}`
|
||||||
|
break
|
||||||
|
case 'spin': {
|
||||||
|
const speed = timeAttribute(element, 'data-mfm-speed', '1s')
|
||||||
|
const delay = timeAttribute(element, 'data-mfm-delay', '0')
|
||||||
|
const animation = element.hasAttribute('data-mfm-x')
|
||||||
|
? 'mfm-spinX'
|
||||||
|
: element.hasAttribute('data-mfm-y')
|
||||||
|
? 'mfm-spinY'
|
||||||
|
: 'mfm-spin'
|
||||||
|
const direction = element.hasAttribute('data-mfm-alternate')
|
||||||
|
? 'alternate'
|
||||||
|
: element.hasAttribute('data-mfm-left')
|
||||||
|
? 'reverse'
|
||||||
|
: 'normal'
|
||||||
|
style = animationRules(animation, speed, delay, direction)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
case 'flip':
|
||||||
|
style = 'transform: scaleX(-1)'
|
||||||
|
break
|
||||||
|
case 'border': {
|
||||||
|
const width = lengthAttribute(element, 'data-mfm-width', '0')
|
||||||
|
const borderStyle = borderStyleAttribute(element)
|
||||||
|
const color = borderColorAttribute(element)
|
||||||
|
const radius = lengthAttribute(element, 'data-mfm-radius', '0')
|
||||||
|
const overflow = element.hasAttribute('data-mfm-noclip') ? 'visible' : 'clip'
|
||||||
|
style = `border: ${width} ${borderStyle} ${color};border-radius: ${radius};overflow: ${overflow}`
|
||||||
|
break
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
if (LOOPING_OPERATORS.has(operator)) {
|
||||||
|
const speed = timeAttribute(element, 'data-mfm-speed', '1s')
|
||||||
|
const delay = timeAttribute(element, 'data-mfm-delay', '0')
|
||||||
|
style = animationRules(`mfm-${operator}`, speed, delay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style) element.setAttribute('style', style)
|
||||||
|
}
|
||||||
|
|
||||||
|
return container.innerHTML
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sanitize, emojify, relink, then apply Pleroma-FE's MFM presentation. */
|
||||||
|
export function renderMfmHtml(
|
||||||
|
source: string | null | undefined,
|
||||||
|
options: MfmRenderOptions = {},
|
||||||
|
): string {
|
||||||
|
return enhanceMfmHtml(renderHtml(source, options), options)
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@
|
|||||||
import Module from '$components/common/Module.svelte'
|
import Module from '$components/common/Module.svelte'
|
||||||
import Avatar from '$components/common/Avatar.svelte'
|
import Avatar from '$components/common/Avatar.svelte'
|
||||||
import RichText from '$components/common/RichText.svelte'
|
import RichText from '$components/common/RichText.svelte'
|
||||||
|
import MfmContent from '$components/common/MfmContent.svelte'
|
||||||
import Composer from '$components/blog/Composer.svelte'
|
import Composer from '$components/blog/Composer.svelte'
|
||||||
|
|
||||||
const { endpoints, session } = useAppServices()
|
const { endpoints, session } = useAppServices()
|
||||||
@@ -328,7 +329,7 @@
|
|||||||
<a class="status-line-author" href={profilePath(entry.account)}>
|
<a class="status-line-author" href={profilePath(entry.account)}>
|
||||||
{displayNameOf(entry.account)}
|
{displayNameOf(entry.account)}
|
||||||
</a>
|
</a>
|
||||||
<RichText
|
<MfmContent
|
||||||
html={entry.spoiler_text ? `<p>${entry.spoiler_text}</p>` : entry.content}
|
html={entry.spoiler_text ? `<p>${entry.spoiler_text}</p>` : entry.content}
|
||||||
emojis={entry.emojis}
|
emojis={entry.emojis}
|
||||||
mentions={entry.mentions}
|
mentions={entry.mentions}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
import { useTimelineRefresh } from '$lib/timeline-refresh'
|
import { useTimelineRefresh } from '$lib/timeline-refresh'
|
||||||
import Module from '$components/common/Module.svelte'
|
import Module from '$components/common/Module.svelte'
|
||||||
import Avatar from '$components/common/Avatar.svelte'
|
import Avatar from '$components/common/Avatar.svelte'
|
||||||
import RichText from '$components/common/RichText.svelte'
|
import MfmContent from '$components/common/MfmContent.svelte'
|
||||||
import BlogEntry from '$components/blog/BlogEntry.svelte'
|
import BlogEntry from '$components/blog/BlogEntry.svelte'
|
||||||
import Composer from '$components/blog/Composer.svelte'
|
import Composer from '$components/blog/Composer.svelte'
|
||||||
|
|
||||||
@@ -229,7 +229,7 @@
|
|||||||
{#if reply.status.spoiler_text}
|
{#if reply.status.spoiler_text}
|
||||||
<details class="content-warning">
|
<details class="content-warning">
|
||||||
<summary class="content-warning-summary">{reply.status.spoiler_text}</summary>
|
<summary class="content-warning-summary">{reply.status.spoiler_text}</summary>
|
||||||
<RichText
|
<MfmContent
|
||||||
html={reply.status.content}
|
html={reply.status.content}
|
||||||
emojis={reply.status.emojis}
|
emojis={reply.status.emojis}
|
||||||
mentions={reply.status.mentions}
|
mentions={reply.status.mentions}
|
||||||
@@ -237,7 +237,7 @@
|
|||||||
/>
|
/>
|
||||||
</details>
|
</details>
|
||||||
{:else}
|
{:else}
|
||||||
<RichText
|
<MfmContent
|
||||||
html={reply.status.content}
|
html={reply.status.content}
|
||||||
emojis={reply.status.emojis}
|
emojis={reply.status.emojis}
|
||||||
mentions={reply.status.mentions}
|
mentions={reply.status.mentions}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
|
|
||||||
@import './tokens.css';
|
@import './tokens.css';
|
||||||
@import './base.css';
|
@import './base.css';
|
||||||
|
@import './mfm.css';
|
||||||
@import './layout.css';
|
@import './layout.css';
|
||||||
@import './chrome.css';
|
@import './chrome.css';
|
||||||
@import './module.css';
|
@import './module.css';
|
||||||
|
|||||||
@@ -0,0 +1,369 @@
|
|||||||
|
/*
|
||||||
|
* MFM presentation ported from Pleroma-FE RichContent at
|
||||||
|
* 38317bee99fbc9d680334eae5dcc33a37bb9777f.
|
||||||
|
*/
|
||||||
|
|
||||||
|
.rich-text {
|
||||||
|
--emoji-size: var(--ms-emoji-size);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text .mfm {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text .mfm.-scale {
|
||||||
|
font-size: calc(var(--emoji-size) / 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text .mfm:not(.-scale) {
|
||||||
|
--emoji-size: 2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text .mfm.-pause {
|
||||||
|
animation-play-state: paused;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text .mfm[data-mfm-operator='x2'] {
|
||||||
|
font-size: 300%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text .mfm[data-mfm-operator='x3'] {
|
||||||
|
font-size: 400%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text .mfm[data-mfm-operator='x4'] {
|
||||||
|
font-size: 600%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text .mfm[data-mfm-operator='sparkle'] {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text .mfm[data-mfm-operator='sparkle']::before,
|
||||||
|
.rich-text .mfm[data-mfm-operator='sparkle']::after {
|
||||||
|
content: '✨';
|
||||||
|
position: absolute;
|
||||||
|
opacity: 0.4;
|
||||||
|
z-index: -1;
|
||||||
|
animation-name: cheap-sparkle;
|
||||||
|
animation-duration: 1s;
|
||||||
|
animation-timing-function: ease-in-out;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text .mfm[data-mfm-operator='sparkle']::before {
|
||||||
|
left: 0;
|
||||||
|
animation-direction: alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text .mfm[data-mfm-operator='sparkle']::after {
|
||||||
|
right: 0;
|
||||||
|
animation-direction: alternate-reverse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text .mfm .custom-emoji {
|
||||||
|
/* Misskey's emoji width knows no bounds. */
|
||||||
|
max-width: unset !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text:hover .mfm {
|
||||||
|
animation-play-state: running;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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(-1em);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
75% {
|
||||||
|
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, 0.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, 0.75);
|
||||||
|
}
|
||||||
|
|
||||||
|
40% {
|
||||||
|
transform: scale(0.75, 1.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
50% {
|
||||||
|
transform: scale(1.15, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
65% {
|
||||||
|
transform: scale(0.95, 1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
75% {
|
||||||
|
transform: scale(1.05, 0.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%);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes cheap-sparkle {
|
||||||
|
0% {
|
||||||
|
filter: hue-rotate() contrast(150%) saturate(150%);
|
||||||
|
transform: translateY(-0.5em);
|
||||||
|
}
|
||||||
|
|
||||||
|
100% {
|
||||||
|
filter: hue-rotate(360deg) contrast(150%) saturate(150%);
|
||||||
|
transform: translateY(0.5em);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user