]> Untitled Git - lemmy-ui.git/blob - src/shared/markdown.ts
fix: Revert to old mobile vote style
[lemmy-ui.git] / src / shared / markdown.ts
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 markdown_it_footnote from "markdown-it-footnote";
12 import markdown_it_html5_embed from "markdown-it-html5-embed";
13 import markdown_it_sub from "markdown-it-sub";
14 import markdown_it_sup from "markdown-it-sup";
15 import Renderer from "markdown-it/lib/renderer";
16 import Token from "markdown-it/lib/token";
17
18 export let Tribute: any;
19
20 export let md: MarkdownIt = new MarkdownIt();
21
22 export let mdNoImages: MarkdownIt = new MarkdownIt();
23
24 export const customEmojis: EmojiMartCategory[] = [];
25
26 export let customEmojisLookup: Map<string, CustomEmojiView> = new Map<
27   string,
28   CustomEmojiView
29 >();
30
31 if (isBrowser()) {
32   Tribute = require("tributejs");
33 }
34
35 export function mdToHtml(text: string) {
36   return { __html: md.render(text) };
37 }
38
39 export function mdToHtmlNoImages(text: string) {
40   return { __html: mdNoImages.render(text) };
41 }
42
43 export function mdToHtmlInline(text: string) {
44   return { __html: md.renderInline(text) };
45 }
46
47 const spoilerConfig = {
48   validate: (params: string) => {
49     return params.trim().match(/^spoiler\s+(.*)$/);
50   },
51
52   render: (tokens: any, idx: any) => {
53     var m = tokens[idx].info.trim().match(/^spoiler\s+(.*)$/);
54
55     if (tokens[idx].nesting === 1) {
56       // opening tag
57       return `<details><summary> ${md.utils.escapeHtml(m[1])} </summary>\n`;
58     } else {
59       // closing tag
60       return "</details>\n";
61     }
62   },
63 };
64
65 const html5EmbedConfig = {
66   html5embed: {
67     useImageSyntax: true, // Enables video/audio embed with ![]() syntax (default)
68     attributes: {
69       audio: 'controls preload="metadata"',
70       video: 'width="100%" max-height="100%" controls loop preload="metadata"',
71     },
72   },
73 };
74
75 export function setupMarkdown() {
76   const markdownItConfig: MarkdownIt.Options = {
77     html: false,
78     linkify: true,
79     typographer: true,
80   };
81
82   // const emojiDefs = Array.from(customEmojisLookup.entries()).reduce(
83   //   (main, [key, value]) => ({ ...main, [key]: value }),
84   //   {}
85   // );
86   md = new MarkdownIt(markdownItConfig)
87     .use(markdown_it_sub)
88     .use(markdown_it_sup)
89     .use(markdown_it_footnote)
90     .use(markdown_it_html5_embed, html5EmbedConfig)
91     .use(markdown_it_container, "spoiler", spoilerConfig);
92   // .use(markdown_it_emoji, {
93   //   defs: emojiDefs,
94   // });
95
96   mdNoImages = new MarkdownIt(markdownItConfig)
97     .use(markdown_it_sub)
98     .use(markdown_it_sup)
99     .use(markdown_it_footnote)
100     .use(markdown_it_html5_embed, html5EmbedConfig)
101     .use(markdown_it_container, "spoiler", spoilerConfig)
102     // .use(markdown_it_emoji, {
103     //   defs: emojiDefs,
104     // })
105     .disable("image");
106   const defaultRenderer = md.renderer.rules.image;
107   md.renderer.rules.image = function (
108     tokens: Token[],
109     idx: number,
110     options: MarkdownIt.Options,
111     env: any,
112     self: Renderer
113   ) {
114     //Provide custom renderer for our emojis to allow us to add a css class and force size dimensions on them.
115     const item = tokens[idx] as any;
116     const title = item.attrs.length >= 3 ? item.attrs[2][1] : "";
117     const src: string = item.attrs[0][1];
118     const isCustomEmoji = customEmojisLookup.get(title) != undefined;
119     if (!isCustomEmoji) {
120       return defaultRenderer?.(tokens, idx, options, env, self) ?? "";
121     }
122     const alt_text = item.content;
123     return `<img class="icon icon-emoji" src="${src}" title="${title}" alt="${alt_text}"/>`;
124   };
125   md.renderer.rules.table_open = function () {
126     return '<table class="table">';
127   };
128 }
129
130 export function setupEmojiDataModel(custom_emoji_views: CustomEmojiView[]) {
131   const groupedEmojis = groupBy(
132     custom_emoji_views,
133     x => x.custom_emoji.category
134   );
135   for (const [category, emojis] of Object.entries(groupedEmojis)) {
136     customEmojis.push({
137       id: category,
138       name: category,
139       emojis: emojis.map(emoji => ({
140         id: emoji.custom_emoji.shortcode,
141         name: emoji.custom_emoji.shortcode,
142         keywords: emoji.keywords.map(x => x.keyword),
143         skins: [{ src: emoji.custom_emoji.image_url }],
144       })),
145     });
146   }
147   customEmojisLookup = new Map(
148     custom_emoji_views.map(view => [view.custom_emoji.shortcode, view])
149   );
150 }
151
152 export function updateEmojiDataModel(custom_emoji_view: CustomEmojiView) {
153   const emoji: EmojiMartCustomEmoji = {
154     id: custom_emoji_view.custom_emoji.shortcode,
155     name: custom_emoji_view.custom_emoji.shortcode,
156     keywords: custom_emoji_view.keywords.map(x => x.keyword),
157     skins: [{ src: custom_emoji_view.custom_emoji.image_url }],
158   };
159   const categoryIndex = customEmojis.findIndex(
160     x => x.id == custom_emoji_view.custom_emoji.category
161   );
162   if (categoryIndex == -1) {
163     customEmojis.push({
164       id: custom_emoji_view.custom_emoji.category,
165       name: custom_emoji_view.custom_emoji.category,
166       emojis: [emoji],
167     });
168   } else {
169     const emojiIndex = customEmojis[categoryIndex].emojis.findIndex(
170       x => x.id == custom_emoji_view.custom_emoji.shortcode
171     );
172     if (emojiIndex == -1) {
173       customEmojis[categoryIndex].emojis.push(emoji);
174     } else {
175       customEmojis[categoryIndex].emojis[emojiIndex] = emoji;
176     }
177   }
178   customEmojisLookup.set(
179     custom_emoji_view.custom_emoji.shortcode,
180     custom_emoji_view
181   );
182 }
183
184 export function removeFromEmojiDataModel(id: number) {
185   let view: CustomEmojiView | undefined;
186   for (const item of customEmojisLookup.values()) {
187     if (item.custom_emoji.id === id) {
188       view = item;
189       break;
190     }
191   }
192   if (!view) return;
193   const categoryIndex = customEmojis.findIndex(
194     x => x.id == view?.custom_emoji.category
195   );
196   const emojiIndex = customEmojis[categoryIndex].emojis.findIndex(
197     x => x.id == view?.custom_emoji.shortcode
198   );
199   customEmojis[categoryIndex].emojis = customEmojis[
200     categoryIndex
201   ].emojis.splice(emojiIndex, 1);
202
203   customEmojisLookup.delete(view?.custom_emoji.shortcode);
204 }
205
206 export function getEmojiMart(
207   onEmojiSelect: (e: any) => void,
208   customPickerOptions: any = {}
209 ) {
210   const pickerOptions = {
211     ...customPickerOptions,
212     onEmojiSelect: onEmojiSelect,
213     custom: customEmojis,
214   };
215   return new Picker(pickerOptions);
216 }
217
218 export function setupTribute() {
219   return new Tribute({
220     noMatchTemplate: function () {
221       return "";
222     },
223     collection: [
224       // Emojis
225       {
226         trigger: ":",
227         menuItemTemplate: (item: any) => {
228           const shortName = `:${item.original.key}:`;
229           return `${item.original.val} ${shortName}`;
230         },
231         selectTemplate: (item: any) => {
232           const customEmoji = customEmojisLookup.get(
233             item.original.key
234           )?.custom_emoji;
235           if (customEmoji == undefined) return `${item.original.val}`;
236           else
237             return `![${customEmoji.alt_text}](${customEmoji.image_url} "${customEmoji.shortcode}")`;
238         },
239         values: Object.entries(emojiShortName)
240           .map(e => {
241             return { key: e[1], val: e[0] };
242           })
243           .concat(
244             Array.from(customEmojisLookup.entries()).map(k => ({
245               key: k[0],
246               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}" />`,
247             }))
248           ),
249         allowSpaces: false,
250         autocompleteMode: true,
251         // TODO
252         // menuItemLimit: mentionDropdownFetchLimit,
253         menuShowMinLength: 2,
254       },
255       // Persons
256       {
257         trigger: "@",
258         selectTemplate: (item: any) => {
259           const it: PersonTribute = item.original;
260           return `[${it.key}](${it.view.person.actor_id})`;
261         },
262         values: debounce(async (text: string, cb: any) => {
263           cb(await personSearch(text));
264         }),
265         allowSpaces: false,
266         autocompleteMode: true,
267         // TODO
268         // menuItemLimit: mentionDropdownFetchLimit,
269         menuShowMinLength: 2,
270       },
271
272       // Communities
273       {
274         trigger: "!",
275         selectTemplate: (item: any) => {
276           const it: CommunityTribute = item.original;
277           return `[${it.key}](${it.view.community.actor_id})`;
278         },
279         values: debounce(async (text: string, cb: any) => {
280           cb(await communitySearch(text));
281         }),
282         allowSpaces: false,
283         autocompleteMode: true,
284         // TODO
285         // menuItemLimit: mentionDropdownFetchLimit,
286         menuShowMinLength: 2,
287       },
288     ],
289   });
290 }
291
292 interface EmojiMartCategory {
293   id: string;
294   name: string;
295   emojis: EmojiMartCustomEmoji[];
296 }
297
298 interface EmojiMartCustomEmoji {
299   id: string;
300   name: string;
301   keywords: string[];
302   skins: EmojiMartSkin[];
303 }
304
305 interface EmojiMartSkin {
306   src: string;
307 }