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