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