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