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