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