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