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