]> Untitled Git - lemmy-ui.git/blob - src/shared/components/common/markdown-textarea.tsx
Merge branch 'main' into breakout-role-utils
[lemmy-ui.git] / src / shared / components / common / markdown-textarea.tsx
1 import autosize from "autosize";
2 import { NoOptionI18nKeys } from "i18next";
3 import { Component, linkEvent } from "inferno";
4 import { Language } from "lemmy-js-client";
5 import { i18n } from "../../i18next";
6 import { HttpService, UserService } from "../../services";
7 import {
8   concurrentImageUpload,
9   customEmojisLookup,
10   markdownFieldCharacterLimit,
11   markdownHelpUrl,
12   maxUploadImages,
13   mdToHtml,
14   numToSI,
15   pictrsDeleteToast,
16   randomStr,
17   relTags,
18   setupTippy,
19   setupTribute,
20   toast,
21 } from "../../utils";
22 import { isBrowser } from "../../utils/browser/is-browser";
23 import { EmojiPicker } from "./emoji-picker";
24 import { Icon, Spinner } from "./icon";
25 import { LanguageSelect } from "./language-select";
26 import NavigationPrompt from "./navigation-prompt";
27 import ProgressBar from "./progress-bar";
28 interface MarkdownTextAreaProps {
29   initialContent?: string;
30   initialLanguageId?: number;
31   placeholder?: string;
32   buttonTitle?: string;
33   maxLength?: number;
34   replyType?: boolean;
35   focus?: boolean;
36   disabled?: boolean;
37   finished?: boolean;
38   showLanguage?: boolean;
39   hideNavigationWarnings?: boolean;
40   onContentChange?(val: string): void;
41   onReplyCancel?(): void;
42   onSubmit?(content: string, formId: string, languageId?: number): void;
43   allLanguages: Language[]; // TODO should probably be nullable
44   siteLanguages: number[]; // TODO same
45 }
46
47 interface ImageUploadStatus {
48   total: number;
49   uploaded: number;
50 }
51
52 interface MarkdownTextAreaState {
53   content?: string;
54   languageId?: number;
55   previewMode: boolean;
56   imageUploadStatus?: ImageUploadStatus;
57   loading: boolean;
58   submitted: boolean;
59 }
60
61 export class MarkdownTextArea extends Component<
62   MarkdownTextAreaProps,
63   MarkdownTextAreaState
64 > {
65   private id = `comment-textarea-${randomStr()}`;
66   private formId = `comment-form-${randomStr()}`;
67   private tribute: any;
68
69   state: MarkdownTextAreaState = {
70     content: this.props.initialContent,
71     languageId: this.props.initialLanguageId,
72     previewMode: false,
73     loading: false,
74     submitted: false,
75   };
76
77   constructor(props: any, context: any) {
78     super(props, context);
79
80     this.handleLanguageChange = this.handleLanguageChange.bind(this);
81
82     if (isBrowser()) {
83       this.tribute = setupTribute();
84     }
85   }
86
87   componentDidMount() {
88     const textarea: any = document.getElementById(this.id);
89     if (textarea) {
90       autosize(textarea);
91       this.tribute.attach(textarea);
92       textarea.addEventListener("tribute-replaced", () => {
93         this.setState({ content: textarea.value });
94         autosize.update(textarea);
95       });
96
97       this.quoteInsert();
98
99       if (this.props.focus) {
100         textarea.focus();
101       }
102
103       // TODO this is slow for some reason
104       setupTippy();
105     }
106   }
107
108   componentWillReceiveProps(nextProps: MarkdownTextAreaProps) {
109     if (nextProps.finished) {
110       this.setState({
111         previewMode: false,
112         imageUploadStatus: undefined,
113         loading: false,
114         content: undefined,
115       });
116       if (this.props.replyType) {
117         this.props.onReplyCancel?.();
118       }
119
120       const textarea: any = document.getElementById(this.id);
121       const form: any = document.getElementById(this.formId);
122       form.reset();
123       setTimeout(() => autosize.update(textarea), 10);
124     }
125   }
126
127   render() {
128     const languageId = this.state.languageId;
129
130     // TODO add these prompts back in at some point
131     // <Prompt
132     //   when={!this.props.hideNavigationWarnings && this.state.content}
133     //   message={i18n.t("block_leaving")}
134     // />
135     return (
136       <form id={this.formId} onSubmit={linkEvent(this, this.handleSubmit)}>
137         <NavigationPrompt
138           when={
139             !this.props.hideNavigationWarnings &&
140             !!this.state.content &&
141             !this.state.submitted
142           }
143         />
144         <div className="form-group row">
145           <div className={`col-sm-12`}>
146             <textarea
147               id={this.id}
148               className={`form-control ${this.state.previewMode && "d-none"}`}
149               value={this.state.content}
150               onInput={linkEvent(this, this.handleContentChange)}
151               onPaste={linkEvent(this, this.handleImageUploadPaste)}
152               onKeyDown={linkEvent(this, this.handleKeyBinds)}
153               required
154               disabled={this.isDisabled}
155               rows={2}
156               maxLength={this.props.maxLength ?? markdownFieldCharacterLimit}
157               placeholder={this.props.placeholder}
158             />
159             {this.state.previewMode && this.state.content && (
160               <div
161                 className="card border-secondary card-body md-div"
162                 dangerouslySetInnerHTML={mdToHtml(this.state.content)}
163               />
164             )}
165             {this.state.imageUploadStatus &&
166               this.state.imageUploadStatus.total > 1 && (
167                 <ProgressBar
168                   className="mt-2"
169                   striped
170                   animated
171                   value={this.state.imageUploadStatus.uploaded}
172                   max={this.state.imageUploadStatus.total}
173                   text={i18n.t("pictures_uploded_progess", {
174                     uploaded: this.state.imageUploadStatus.uploaded,
175                     total: this.state.imageUploadStatus.total,
176                   })}
177                 />
178               )}
179           </div>
180           <label className="sr-only" htmlFor={this.id}>
181             {i18n.t("body")}
182           </label>
183         </div>
184         <div className="row">
185           <div className="col-sm-12 d-flex flex-wrap">
186             {this.getFormatButton("bold", this.handleInsertBold)}
187             {this.getFormatButton("italic", this.handleInsertItalic)}
188             {this.getFormatButton("link", this.handleInsertLink)}
189             <EmojiPicker
190               onEmojiClick={e => this.handleEmoji(this, e)}
191               disabled={this.isDisabled}
192             ></EmojiPicker>
193             <form className="btn btn-sm text-muted font-weight-bold">
194               <label
195                 htmlFor={`file-upload-${this.id}`}
196                 className={`mb-0 ${
197                   UserService.Instance.myUserInfo && "pointer"
198                 }`}
199                 data-tippy-content={i18n.t("upload_image")}
200               >
201                 {this.state.imageUploadStatus ? (
202                   <Spinner />
203                 ) : (
204                   <Icon icon="image" classes="icon-inline" />
205                 )}
206               </label>
207               <input
208                 id={`file-upload-${this.id}`}
209                 type="file"
210                 accept="image/*,video/*"
211                 name="file"
212                 className="d-none"
213                 multiple
214                 disabled={!UserService.Instance.myUserInfo || this.isDisabled}
215                 onChange={linkEvent(this, this.handleImageUpload)}
216               />
217             </form>
218             {this.getFormatButton("header", this.handleInsertHeader)}
219             {this.getFormatButton(
220               "strikethrough",
221               this.handleInsertStrikethrough
222             )}
223             {this.getFormatButton("quote", this.handleInsertQuote)}
224             {this.getFormatButton("list", this.handleInsertList)}
225             {this.getFormatButton("code", this.handleInsertCode)}
226             {this.getFormatButton("subscript", this.handleInsertSubscript)}
227             {this.getFormatButton("superscript", this.handleInsertSuperscript)}
228             {this.getFormatButton("spoiler", this.handleInsertSpoiler)}
229             <a
230               href={markdownHelpUrl}
231               className="btn btn-sm text-muted font-weight-bold"
232               title={i18n.t("formatting_help")}
233               rel={relTags}
234             >
235               <Icon icon="help-circle" classes="icon-inline" />
236             </a>
237           </div>
238
239           <div className="col-sm-12 d-flex align-items-center flex-wrap">
240             {this.props.showLanguage && (
241               <LanguageSelect
242                 iconVersion
243                 allLanguages={this.props.allLanguages}
244                 selectedLanguageIds={
245                   languageId ? Array.of(languageId) : undefined
246                 }
247                 siteLanguages={this.props.siteLanguages}
248                 onChange={this.handleLanguageChange}
249                 disabled={this.isDisabled}
250               />
251             )}
252
253             {/* A flex expander */}
254             <div className="flex-grow-1"></div>
255
256             {this.props.buttonTitle && (
257               <button
258                 type="submit"
259                 className="btn btn-sm btn-secondary mr-2"
260                 disabled={this.isDisabled}
261               >
262                 {this.state.loading ? (
263                   <Spinner />
264                 ) : (
265                   <span>{this.props.buttonTitle}</span>
266                 )}
267               </button>
268             )}
269             {this.props.replyType && (
270               <button
271                 type="button"
272                 className="btn btn-sm btn-secondary mr-2"
273                 onClick={linkEvent(this, this.handleReplyCancel)}
274               >
275                 {i18n.t("cancel")}
276               </button>
277             )}
278             {this.state.content && (
279               <button
280                 className={`btn btn-sm btn-secondary mr-2 ${
281                   this.state.previewMode && "active"
282                 }`}
283                 onClick={linkEvent(this, this.handlePreviewToggle)}
284               >
285                 {this.state.previewMode ? i18n.t("edit") : i18n.t("preview")}
286               </button>
287             )}
288           </div>
289         </div>
290       </form>
291     );
292   }
293
294   getFormatButton(
295     type: NoOptionI18nKeys,
296     handleClick: (i: MarkdownTextArea, event: any) => void
297   ) {
298     let iconType: string;
299
300     switch (type) {
301       case "spoiler": {
302         iconType = "alert-triangle";
303         break;
304       }
305       case "quote": {
306         iconType = "format_quote";
307         break;
308       }
309       default: {
310         iconType = type;
311       }
312     }
313
314     return (
315       <button
316         className="btn btn-sm text-muted"
317         data-tippy-content={i18n.t(type)}
318         aria-label={i18n.t(type)}
319         onClick={linkEvent(this, handleClick)}
320         disabled={this.isDisabled}
321       >
322         <Icon icon={iconType} classes="icon-inline" />
323       </button>
324     );
325   }
326
327   handleEmoji(i: MarkdownTextArea, e: any) {
328     let value = e.native;
329     if (value == null) {
330       const emoji = customEmojisLookup.get(e.id)?.custom_emoji;
331       if (emoji) {
332         value = `![${emoji.alt_text}](${emoji.image_url} "${emoji.shortcode}")`;
333       }
334     }
335     i.setState({
336       content: `${i.state.content ?? ""} ${value} `,
337     });
338     i.contentChange();
339     const textarea: any = document.getElementById(i.id);
340     autosize.update(textarea);
341   }
342
343   handleImageUploadPaste(i: MarkdownTextArea, event: any) {
344     const image = event.clipboardData.files[0];
345     if (image) {
346       i.handleImageUpload(i, image);
347     }
348   }
349
350   handleImageUpload(i: MarkdownTextArea, event: any) {
351     const files: File[] = [];
352     if (event.target) {
353       event.preventDefault();
354       files.push(...event.target.files);
355     } else {
356       files.push(event);
357     }
358
359     if (files.length > maxUploadImages) {
360       toast(
361         i18n.t("too_many_images_upload", {
362           count: Number(maxUploadImages),
363           formattedCount: numToSI(maxUploadImages),
364         }),
365         "danger"
366       );
367     } else {
368       i.setState({
369         imageUploadStatus: { total: files.length, uploaded: 0 },
370       });
371
372       i.uploadImages(i, files).then(() => {
373         i.setState({ imageUploadStatus: undefined });
374       });
375     }
376   }
377
378   async uploadImages(i: MarkdownTextArea, files: File[]) {
379     let errorOccurred = false;
380     const filesCopy = [...files];
381     while (filesCopy.length > 0 && !errorOccurred) {
382       try {
383         await Promise.all(
384           filesCopy.splice(0, concurrentImageUpload).map(async file => {
385             await i.uploadSingleImage(i, file);
386
387             this.setState(({ imageUploadStatus }) => ({
388               imageUploadStatus: {
389                 ...(imageUploadStatus as Required<ImageUploadStatus>),
390                 uploaded: (imageUploadStatus?.uploaded ?? 0) + 1,
391               },
392             }));
393           })
394         );
395       } catch (e) {
396         errorOccurred = true;
397       }
398     }
399   }
400
401   async uploadSingleImage(i: MarkdownTextArea, image: File) {
402     const res = await HttpService.client.uploadImage({ image });
403     console.log("pictrs upload:");
404     console.log(res);
405     if (res.state === "success") {
406       if (res.data.msg === "ok") {
407         const imageMarkdown = `![](${res.data.url})`;
408         i.setState(({ content }) => ({
409           content: content ? `${content}\n${imageMarkdown}` : imageMarkdown,
410         }));
411         i.contentChange();
412         const textarea: any = document.getElementById(i.id);
413         autosize.update(textarea);
414         pictrsDeleteToast(image.name, res.data.delete_url as string);
415       } else {
416         throw JSON.stringify(res.data);
417       }
418     } else if (res.state === "failed") {
419       i.setState({ imageUploadStatus: undefined });
420       console.error(res.msg);
421       toast(res.msg, "danger");
422
423       throw res.msg;
424     }
425   }
426
427   contentChange() {
428     // Coerces the undefineds to empty strings, for replacing in the DB
429     const content = this.state.content ?? "";
430     this.props.onContentChange?.(content);
431   }
432
433   handleContentChange(i: MarkdownTextArea, event: any) {
434     i.setState({ content: event.target.value });
435     i.contentChange();
436   }
437
438   // Keybind handler
439   // Keybinds inspired by github comment area
440   handleKeyBinds(i: MarkdownTextArea, event: KeyboardEvent) {
441     if (event.ctrlKey) {
442       switch (event.key) {
443         case "k": {
444           i.handleInsertLink(i, event);
445           break;
446         }
447         case "Enter": {
448           if (!this.isDisabled) {
449             i.handleSubmit(i, event);
450           }
451
452           break;
453         }
454         case "b": {
455           i.handleInsertBold(i, event);
456           break;
457         }
458         case "i": {
459           i.handleInsertItalic(i, event);
460           break;
461         }
462         case "e": {
463           i.handleInsertCode(i, event);
464           break;
465         }
466         case "8": {
467           i.handleInsertList(i, event);
468           break;
469         }
470         case "s": {
471           i.handleInsertSpoiler(i, event);
472           break;
473         }
474         case "p": {
475           if (i.state.content) i.handlePreviewToggle(i, event);
476           break;
477         }
478         case ".": {
479           i.handleInsertQuote(i, event);
480           break;
481         }
482       }
483     }
484   }
485
486   handlePreviewToggle(i: MarkdownTextArea, event: any) {
487     event.preventDefault();
488     i.setState({ previewMode: !i.state.previewMode });
489   }
490
491   handleLanguageChange(val: number[]) {
492     this.setState({ languageId: val[0] });
493   }
494
495   handleSubmit(i: MarkdownTextArea, event: any) {
496     event.preventDefault();
497     if (i.state.content) {
498       i.setState({ loading: true, submitted: true });
499       i.props.onSubmit?.(i.state.content, i.formId, i.state.languageId);
500     }
501   }
502
503   handleReplyCancel(i: MarkdownTextArea) {
504     i.props.onReplyCancel?.();
505   }
506
507   handleInsertLink(i: MarkdownTextArea, event: any) {
508     event.preventDefault();
509
510     const textarea: any = document.getElementById(i.id);
511     const start: number = textarea.selectionStart;
512     const end: number = textarea.selectionEnd;
513
514     const content = i.state.content ?? "";
515
516     if (!i.state.content) {
517       i.setState({ content: "" });
518     }
519
520     if (start !== end) {
521       const selectedText = content?.substring(start, end);
522       i.setState({
523         content: `${content?.substring(
524           0,
525           start
526         )}[${selectedText}]()${content?.substring(end)}`,
527       });
528       textarea.focus();
529       setTimeout(() => (textarea.selectionEnd = end + 3), 10);
530     } else {
531       i.setState({ content: `${content} []()` });
532       textarea.focus();
533       setTimeout(() => (textarea.selectionEnd -= 1), 10);
534     }
535     i.contentChange();
536   }
537
538   simpleSurround(chars: string) {
539     this.simpleSurroundBeforeAfter(chars, chars);
540   }
541
542   simpleBeginningofLine(chars: string) {
543     this.simpleSurroundBeforeAfter(`${chars}`, "", "");
544   }
545
546   simpleSurroundBeforeAfter(
547     beforeChars: string,
548     afterChars: string,
549     emptyChars = "___"
550   ) {
551     const content = this.state.content ?? "";
552     if (!this.state.content) {
553       this.setState({ content: "" });
554     }
555     const textarea: any = document.getElementById(this.id);
556     const start: number = textarea.selectionStart;
557     const end: number = textarea.selectionEnd;
558
559     if (start !== end) {
560       const selectedText = content?.substring(start, end);
561       this.setState({
562         content: `${content?.substring(
563           0,
564           start
565         )}${beforeChars}${selectedText}${afterChars}${content?.substring(end)}`,
566       });
567     } else {
568       this.setState({
569         content: `${content}${beforeChars}${emptyChars}${afterChars}`,
570       });
571     }
572     this.contentChange();
573
574     textarea.focus();
575
576     if (start !== end) {
577       textarea.setSelectionRange(
578         start + beforeChars.length,
579         end + afterChars.length
580       );
581     } else {
582       textarea.setSelectionRange(
583         start + beforeChars.length,
584         end + emptyChars.length + afterChars.length
585       );
586     }
587
588     setTimeout(() => {
589       autosize.update(textarea);
590     }, 10);
591   }
592
593   handleInsertBold(i: MarkdownTextArea, event: any) {
594     event.preventDefault();
595     i.simpleSurround("**");
596   }
597
598   handleInsertItalic(i: MarkdownTextArea, event: any) {
599     event.preventDefault();
600     i.simpleSurround("*");
601   }
602
603   handleInsertCode(i: MarkdownTextArea, event: any) {
604     event.preventDefault();
605     if (i.getSelectedText().split(/\r*\n/).length > 1) {
606       i.simpleSurroundBeforeAfter("```\n", "\n```");
607     } else {
608       i.simpleSurround("`");
609     }
610   }
611
612   handleInsertStrikethrough(i: MarkdownTextArea, event: any) {
613     event.preventDefault();
614     i.simpleSurround("~~");
615   }
616
617   handleInsertList(i: MarkdownTextArea, event: any) {
618     event.preventDefault();
619     i.simpleBeginningofLine(`-${i.getSelectedText() ? " " : ""}`);
620   }
621
622   handleInsertQuote(i: MarkdownTextArea, event: any) {
623     event.preventDefault();
624     i.simpleBeginningofLine(">");
625   }
626
627   handleInsertHeader(i: MarkdownTextArea, event: any) {
628     event.preventDefault();
629     i.simpleBeginningofLine("#");
630   }
631
632   handleInsertSubscript(i: MarkdownTextArea, event: any) {
633     event.preventDefault();
634     i.simpleSurround("~");
635   }
636
637   handleInsertSuperscript(i: MarkdownTextArea, event: any) {
638     event.preventDefault();
639     i.simpleSurround("^");
640   }
641
642   simpleInsert(chars: string) {
643     const content = this.state.content;
644     if (!content) {
645       this.setState({ content: `${chars} ` });
646     } else {
647       this.setState({
648         content: `${content}\n${chars} `,
649       });
650     }
651
652     const textarea: any = document.getElementById(this.id);
653     textarea.focus();
654     setTimeout(() => {
655       autosize.update(textarea);
656     }, 10);
657     this.contentChange();
658   }
659
660   handleInsertSpoiler(i: MarkdownTextArea, event: any) {
661     event.preventDefault();
662     const beforeChars = `\n::: spoiler ${i18n.t("spoiler")}\n`;
663     const afterChars = "\n:::\n";
664     i.simpleSurroundBeforeAfter(beforeChars, afterChars);
665   }
666
667   quoteInsert() {
668     const textarea: any = document.getElementById(this.id);
669     const selectedText = window.getSelection()?.toString();
670     const { content } = this.state;
671     if (selectedText) {
672       const quotedText =
673         selectedText
674           .split("\n")
675           .map(t => `> ${t}`)
676           .join("\n") + "\n\n";
677       if (!content) {
678         this.setState({ content: "" });
679       } else {
680         this.setState({ content: `${content}\n` });
681       }
682       this.setState({
683         content: `${content}${quotedText}`,
684       });
685       this.contentChange();
686       // Not sure why this needs a delay
687       setTimeout(() => autosize.update(textarea), 10);
688     }
689   }
690
691   getSelectedText(): string {
692     const { selectionStart: start, selectionEnd: end } =
693       document.getElementById(this.id) as any;
694     return start !== end ? this.state.content?.substring(start, end) ?? "" : "";
695   }
696
697   get isDisabled() {
698     return (
699       this.state.loading ||
700       this.props.disabled ||
701       !!this.state.imageUploadStatus
702     );
703   }
704 }