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