1 import { communitySearch, personSearch } from "@utils/app";
2 import { isBrowser } from "@utils/browser";
3 import { debounce, groupBy } from "@utils/helpers";
4 import { CommunityTribute, PersonTribute } from "@utils/types";
5 import { Picker } from "emoji-mart";
6 import emojiShortName from "emoji-short-name";
7 import { CustomEmojiView } from "lemmy-js-client";
8 import { default as MarkdownIt } from "markdown-it";
9 import markdown_it_container from "markdown-it-container";
10 // import markdown_it_emoji from "markdown-it-emoji/bare";
11 import { getHttpBase } from "@utils/env";
12 import markdown_it_footnote from "markdown-it-footnote";
13 import markdown_it_html5_embed from "markdown-it-html5-embed";
14 import markdown_it_sub from "markdown-it-sub";
15 import markdown_it_sup from "markdown-it-sup";
16 import Renderer from "markdown-it/lib/renderer";
17 import Token from "markdown-it/lib/token";
19 export let Tribute: any;
21 export let md: MarkdownIt = new MarkdownIt();
23 export let mdNoImages: MarkdownIt = new MarkdownIt();
25 export const customEmojis: EmojiMartCategory[] = [];
27 export let customEmojisLookup: Map<string, CustomEmojiView> = new Map<
33 Tribute = require("tributejs");
36 export function mdToHtml(text: string) {
37 return { __html: md.render(text) };
40 export function mdToHtmlNoImages(text: string) {
41 return { __html: mdNoImages.render(text) };
44 export function mdToHtmlInline(text: string) {
45 return { __html: md.renderInline(text) };
48 const spoilerConfig = {
49 validate: (params: string) => {
50 return params.trim().match(/^spoiler\s+(.*)$/);
53 render: (tokens: any, idx: any) => {
54 var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/);
56 if (tokens[idx].nesting === 1) {
58 return `<details><summary> ${md.utils.escapeHtml(m[1])} </summary>\n`;
61 return "</details>\n";
66 const html5EmbedConfig = {
68 useImageSyntax: true, // Enables video/audio embed with ![]() syntax (default)
70 audio: 'controls preload="metadata"',
71 video: 'width="100%" max-height="100%" controls loop preload="metadata"',
76 function localCommunityLinkParser(md) {
78 /(!\b[^@\s]+@[^@\s]+\.[^.\s]+\b)|\/c\/([^@\s]+)(@[^@\s]+\.[^.\s]+\b)?/g;
80 md.core.ruler.push("replace-text", state => {
81 const tokens = state.tokens;
83 for (let i = 0; i < tokens.length; i++) {
84 if (tokens[i].type === "inline") {
85 const token = tokens[i];
87 const originalContent = token.content;
90 originalContent.replace(
92 (match, fullDomainMatch, name, domainTld, index) => {
94 // ex: !Testing@example.com
95 if (fullDomainMatch) {
96 const [name, domain, tld] = fullDomainMatch
101 url = `${getHttpBase()}/c/${name}@${domain}.${tld}`;
103 // ex: /c/Testing or /c/Testing@example.com
104 url = `${getHttpBase()}/c/${name}${domainTld || ""}`;
107 const beforeContent = originalContent.slice(lastIndex, index);
108 lastIndex = index + match.length;
110 const beforeToken = new state.Token("text", "", 0);
111 beforeToken.content = beforeContent;
113 const linkOpenToken = new state.Token("link_open", "a", 1);
114 linkOpenToken.attrs = [
116 ["class", "community-link"],
119 const textToken = new state.Token("text", "", 0);
120 textToken.content = match;
122 const linkCloseToken = new state.Token("link_close", "a", -1);
124 const afterContent = originalContent.slice(lastIndex);
125 const afterToken = new state.Token("text", "", 0);
126 afterToken.content = afterContent;
140 // Update i to skip the newly added tokens
149 export function setupMarkdown() {
150 const markdownItConfig: MarkdownIt.Options = {
156 // const emojiDefs = Array.from(customEmojisLookup.entries()).reduce(
157 // (main, [key, value]) => ({ ...main, [key]: value }),
160 md = new MarkdownIt(markdownItConfig)
161 .use(markdown_it_sub)
162 .use(markdown_it_sup)
163 .use(markdown_it_footnote)
164 .use(markdown_it_html5_embed, html5EmbedConfig)
165 .use(markdown_it_container, "spoiler", spoilerConfig)
166 .use(localCommunityLinkParser);
167 // .use(markdown_it_emoji, {
171 mdNoImages = new MarkdownIt(markdownItConfig)
172 .use(markdown_it_sub)
173 .use(markdown_it_sup)
174 .use(markdown_it_footnote)
175 .use(markdown_it_html5_embed, html5EmbedConfig)
176 .use(markdown_it_container, "spoiler", spoilerConfig)
177 .use(localCommunityLinkParser)
178 // .use(markdown_it_emoji, {
182 const defaultRenderer = md.renderer.rules.image;
183 md.renderer.rules.image = function (
186 options: MarkdownIt.Options,
190 //Provide custom renderer for our emojis to allow us to add a css class and force size dimensions on them.
191 const item = tokens[idx] as any;
192 const title = item.attrs.length >= 3 ? item.attrs[2][1] : "";
193 const src: string = item.attrs[0][1];
194 const isCustomEmoji = customEmojisLookup.get(title) != undefined;
195 if (!isCustomEmoji) {
196 return defaultRenderer?.(tokens, idx, options, env, self) ?? "";
198 const alt_text = item.content;
199 return `<img class="icon icon-emoji" src="${src}" title="${title}" alt="${alt_text}"/>`;
201 md.renderer.rules.table_open = function () {
202 return '<table class="table">';
206 export function setupEmojiDataModel(custom_emoji_views: CustomEmojiView[]) {
207 const groupedEmojis = groupBy(
209 x => x.custom_emoji.category
211 for (const [category, emojis] of Object.entries(groupedEmojis)) {
215 emojis: emojis.map(emoji => ({
216 id: emoji.custom_emoji.shortcode,
217 name: emoji.custom_emoji.shortcode,
218 keywords: emoji.keywords.map(x => x.keyword),
219 skins: [{ src: emoji.custom_emoji.image_url }],
223 customEmojisLookup = new Map(
224 custom_emoji_views.map(view => [view.custom_emoji.shortcode, view])
228 export function updateEmojiDataModel(custom_emoji_view: CustomEmojiView) {
229 const emoji: EmojiMartCustomEmoji = {
230 id: custom_emoji_view.custom_emoji.shortcode,
231 name: custom_emoji_view.custom_emoji.shortcode,
232 keywords: custom_emoji_view.keywords.map(x => x.keyword),
233 skins: [{ src: custom_emoji_view.custom_emoji.image_url }],
235 const categoryIndex = customEmojis.findIndex(
236 x => x.id == custom_emoji_view.custom_emoji.category
238 if (categoryIndex == -1) {
240 id: custom_emoji_view.custom_emoji.category,
241 name: custom_emoji_view.custom_emoji.category,
245 const emojiIndex = customEmojis[categoryIndex].emojis.findIndex(
246 x => x.id == custom_emoji_view.custom_emoji.shortcode
248 if (emojiIndex == -1) {
249 customEmojis[categoryIndex].emojis.push(emoji);
251 customEmojis[categoryIndex].emojis[emojiIndex] = emoji;
254 customEmojisLookup.set(
255 custom_emoji_view.custom_emoji.shortcode,
260 export function removeFromEmojiDataModel(id: number) {
261 let view: CustomEmojiView | undefined;
262 for (const item of customEmojisLookup.values()) {
263 if (item.custom_emoji.id === id) {
269 const categoryIndex = customEmojis.findIndex(
270 x => x.id == view?.custom_emoji.category
272 const emojiIndex = customEmojis[categoryIndex].emojis.findIndex(
273 x => x.id == view?.custom_emoji.shortcode
275 customEmojis[categoryIndex].emojis = customEmojis[
277 ].emojis.splice(emojiIndex, 1);
279 customEmojisLookup.delete(view?.custom_emoji.shortcode);
282 export function getEmojiMart(
283 onEmojiSelect: (e: any) => void,
284 customPickerOptions: any = {}
286 const pickerOptions = {
287 ...customPickerOptions,
288 onEmojiSelect: onEmojiSelect,
289 custom: customEmojis,
291 return new Picker(pickerOptions);
294 export function setupTribute() {
296 noMatchTemplate: function () {
303 menuItemTemplate: (item: any) => {
304 const shortName = `:${item.original.key}:`;
305 return `${item.original.val} ${shortName}`;
307 selectTemplate: (item: any) => {
308 const customEmoji = customEmojisLookup.get(
311 if (customEmoji == undefined) return `${item.original.val}`;
313 return `![${customEmoji.alt_text}](${customEmoji.image_url} "${customEmoji.shortcode}")`;
315 values: Object.entries(emojiShortName)
317 return { key: e[1], val: e[0] };
320 Array.from(customEmojisLookup.entries()).map(k => ({
322 val: `<img class="icon icon-emoji" src="${k[1].custom_emoji.image_url}" title="${k[1].custom_emoji.shortcode}" alt="${k[1].custom_emoji.alt_text}" />`,
326 autocompleteMode: true,
328 // menuItemLimit: mentionDropdownFetchLimit,
329 menuShowMinLength: 2,
334 selectTemplate: (item: any) => {
335 const it: PersonTribute = item.original;
336 return `[${it.key}](${it.view.person.actor_id})`;
338 values: debounce(async (text: string, cb: any) => {
339 cb(await personSearch(text));
342 autocompleteMode: true,
344 // menuItemLimit: mentionDropdownFetchLimit,
345 menuShowMinLength: 2,
351 selectTemplate: (item: any) => {
352 const it: CommunityTribute = item.original;
353 return `[${it.key}](${it.view.community.actor_id})`;
355 values: debounce(async (text: string, cb: any) => {
356 cb(await communitySearch(text));
359 autocompleteMode: true,
361 // menuItemLimit: mentionDropdownFetchLimit,
362 menuShowMinLength: 2,
368 interface EmojiMartCategory {
371 emojis: EmojiMartCustomEmoji[];
374 interface EmojiMartCustomEmoji {
378 skins: EmojiMartSkin[];
381 interface EmojiMartSkin {