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