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