working HTML->JSX renderer with emoji, mentions, and hashtags
This commit is contained in:
parent
882c2471e8
commit
0988ab55e2
6 changed files with 155 additions and 45 deletions
|
@ -1,42 +0,0 @@
|
|||
import * as htmlparser2 from "htmlparser2";
|
||||
|
||||
import type { Mention, Tag } from "@/lib/api/entities/activity";
|
||||
import type { CustomEmoji } from "@/lib/api/entities/custom_emoji";
|
||||
import { Text, type ChildNode, Element } from "domhandler";
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
emoji: CustomEmoji[];
|
||||
tags?: Tag[];
|
||||
mentions?: Mention[];
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
|
||||
export default function HTMLContent(props: Props) {
|
||||
let { content } = props;
|
||||
// emoji are always in their shortcode form
|
||||
props.emoji.forEach((emoji) => {
|
||||
content = content.replace(new RegExp(`:${emoji.shortcode}:`, "g"), customEmojiElement(emoji));
|
||||
});
|
||||
|
||||
const dom = htmlparser2.parseDocument(content);
|
||||
const elements = parseElement(dom.children);
|
||||
|
||||
console.log(dom);
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
const customEmojiElement = (emoji: CustomEmoji) => {
|
||||
const elem = document.createElement("x-emoji");
|
||||
elem.setAttribute("emoji", emoji.shortcode);
|
||||
return elem.outerHTML;
|
||||
};
|
||||
|
||||
function parseElement(elems: ChildNode[]) {
|
||||
const out = [];
|
||||
for (const elem of elems) {
|
||||
if (elem instanceof Text) out.push(<>{elem.data}</>);
|
||||
}
|
||||
}
|
|
@ -3,7 +3,7 @@ import { ref } from "vue";
|
|||
import { useI18n } from "vue-i18n";
|
||||
import type Activity from "@/lib/api/entities/activity";
|
||||
import { FwbButton } from "flowbite-vue";
|
||||
import HTMLContent from "../HTMLContent";
|
||||
import HTMLContent from "./content/HTMLContent";
|
||||
|
||||
const { status } = defineProps<{ status: Activity }>();
|
||||
|
||||
|
@ -24,7 +24,6 @@ const isCollapsed = ref(!!status.spoiler_text);
|
|||
}}
|
||||
</FwbButton>
|
||||
</div>
|
||||
<div v-if="!isCollapsed" v-html="status.content || 'N/A'"></div>
|
||||
<HTMLContent
|
||||
v-if="!isCollapsed && status.content"
|
||||
:content="status.content"
|
||||
|
|
124
src/components/status/content/HTMLContent.tsx
Normal file
124
src/components/status/content/HTMLContent.tsx
Normal file
|
@ -0,0 +1,124 @@
|
|||
import * as htmlparser2 from "htmlparser2";
|
||||
import { h } from "vue";
|
||||
|
||||
import type { Mention, Tag } from "@/lib/api/entities/activity";
|
||||
import type { CustomEmoji } from "@/lib/api/entities/custom_emoji";
|
||||
import { Document, Text, type ChildNode, Element } from "domhandler";
|
||||
|
||||
import VulEmoji from "./VulEmoji.vue";
|
||||
import VulMention from "./VulMention.vue";
|
||||
import VulHashtag from "./VulHashtag.vue";
|
||||
|
||||
interface Props {
|
||||
content: string;
|
||||
emoji: CustomEmoji[];
|
||||
tags?: Tag[];
|
||||
mentions?: Mention[];
|
||||
}
|
||||
|
||||
export default function HTMLContent(props: Props) {
|
||||
let { content } = props;
|
||||
// emoji are always in their shortcode form
|
||||
props.emoji.forEach((emoji) => {
|
||||
content = content.replace(new RegExp(`:${emoji.shortcode}:`, "g"), customEmojiElement(emoji));
|
||||
});
|
||||
|
||||
const dom = htmlparser2.parseDocument(content);
|
||||
const elements = parseElement(dom, props.emoji, props.tags || [], props.mentions || []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<code>
|
||||
<pre>{dom}</pre>
|
||||
</code>
|
||||
{elements}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Create a custom emoji element and return its string representation.
|
||||
* Akkoma server renders HTML but keeps emoji in their shortcode form,
|
||||
* so we turn it into a fake custom element so parseElement knows what to do with it.
|
||||
*/
|
||||
const customEmojiElement = (emoji: CustomEmoji) => {
|
||||
const elem = document.createElement("x-emoji");
|
||||
elem.setAttribute("emoji", emoji.shortcode);
|
||||
return elem.outerHTML;
|
||||
};
|
||||
|
||||
/** Recursively parses an element into a JSX representation. */
|
||||
function parseElement(elem: ChildNode, emoji: CustomEmoji[], tags: Tag[], mentions: Mention[]) {
|
||||
const out = [] as JSX.Element[];
|
||||
|
||||
if (elem instanceof Document) {
|
||||
// root, start parsing elements
|
||||
out.push(...(elem.children as ChildNode[]).map((e) => parseElement(e, emoji, tags, mentions)).flat());
|
||||
} else if (elem instanceof Text) {
|
||||
// text node, just add it to the output unmodified
|
||||
out.push(<>{elem.data}</>);
|
||||
} else if (elem instanceof Element) {
|
||||
console.log(elem.name, elem.attribs);
|
||||
|
||||
switch (elem.name) {
|
||||
case "x-emoji":
|
||||
// Turn <x-emoji> into a VulEmoji component
|
||||
out.push(h(VulEmoji, { emoji: emoji.find((e) => e.shortcode === elem.attribs["emoji"]!)! }));
|
||||
break;
|
||||
|
||||
case "span":
|
||||
// check if this is a mention
|
||||
if ("class" in elem.attribs && elem.attribs.class === "h-card") {
|
||||
// if so, parse that mention and add a component for it
|
||||
if (elem.children.length === 0) throw "Invalid mention, span with type h-card doesn't have child";
|
||||
const userId = (elem.children[0] as Element).attribs["data-user"];
|
||||
const user = mentions.find((m) => m.id === userId);
|
||||
if (!user) throw "Invalid mention, user is not found in Status.mentions";
|
||||
out.push(h(VulMention, { mention: user }));
|
||||
} else {
|
||||
// else, just treat it as a normal span
|
||||
out.push(
|
||||
h(
|
||||
elem.name,
|
||||
elem.attribs,
|
||||
elem.children.map((e) => parseElement(e, emoji, tags, mentions)),
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "a":
|
||||
// check if this is a hashtag
|
||||
if (elem.attribs.class == "hashtag") {
|
||||
// if so, get the hashtag and add a VulHashtag component
|
||||
const tagName = elem.attribs["data-tag"];
|
||||
const tag = tags.find((t) => t.name === tagName);
|
||||
if (!tag) throw "Invalid hashtag, tag is not found in Status.tags";
|
||||
out.push(h(VulHashtag, { tag: tag }));
|
||||
} else {
|
||||
// else, just treat it as a normal element
|
||||
out.push(
|
||||
h(
|
||||
elem.name,
|
||||
{ ...elem.attribs, target: "_blank" },
|
||||
elem.children.map((e) => parseElement(e, emoji, tags, mentions)),
|
||||
),
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
out.push(
|
||||
h(
|
||||
elem.name,
|
||||
elem.attribs,
|
||||
elem.children.map((e) => parseElement(e, emoji, tags, mentions)),
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
h(elem.name, {});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
|
@ -5,5 +5,12 @@ defineProps<{ emoji: CustomEmoji }>();
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<img class="inline-emoji" :src="emoji.url" :alt="emoji.shortcode" />
|
||||
<img class="emoji" :src="emoji.url" :alt="emoji.shortcode" :title="emoji.shortcode" />
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.emoji {
|
||||
display: inline;
|
||||
height: 38px;
|
||||
}
|
||||
</style>
|
11
src/components/status/content/VulHashtag.vue
Normal file
11
src/components/status/content/VulHashtag.vue
Normal file
|
@ -0,0 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import type { Tag } from "@/lib/api/entities/activity";
|
||||
|
||||
defineProps<{ tag: Tag }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="text-sky-600 underline hover:text-sky-700 dark:text-sky-300 dark:hover:text-sky-400">
|
||||
<RouterLink :to="`/tags/${tag.name}`">#{{ tag.name }}</RouterLink>
|
||||
</span>
|
||||
</template>
|
11
src/components/status/content/VulMention.vue
Normal file
11
src/components/status/content/VulMention.vue
Normal file
|
@ -0,0 +1,11 @@
|
|||
<script setup lang="ts">
|
||||
import type { Mention } from "@/lib/api/entities/activity";
|
||||
|
||||
defineProps<{ mention: Mention }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="bg-gray-400 dark:bg-gray-700 hover:bg-gray-500 dark:hover:bg-gray-600 p-1 rounded-full">
|
||||
<RouterLink :to="`/@${mention.acct}`">@{{ mention.acct }}</RouterLink>
|
||||
</span>
|
||||
</template>
|
Loading…
Reference in a new issue