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