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