mirror of
https://git.shipoclu.com/moon/plspace.git
synced 2026-08-14 03:02:31 +00:00
add mfm support
This commit is contained in:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user