]> Untitled Git - lemmy-ui.git/blob - src/shared/components/common/markdown-textarea.tsx
Merge branch 'main' into fix/a11y-fixes
[lemmy-ui.git] / src / shared / components / common / markdown-textarea.tsx
1 import { isBrowser } from "@utils/browser";
2 import { numToSI, randomStr } from "@utils/helpers";
3 import autosize from "autosize";
4 import classNames from "classnames";
5 import { NoOptionI18nKeys } from "i18next";
6 import { Component, linkEvent } from "inferno";
7 import { Language } from "lemmy-js-client";
8 import {
9   concurrentImageUpload,
10   markdownFieldCharacterLimit,
11   markdownHelpUrl,
12   maxUploadImages,
13   relTags,
14 } from "../../config";
15 import { customEmojisLookup, mdToHtml, setupTribute } from "../../markdown";
16 import { HttpService, I18NextService, UserService } from "../../services";
17 import { setupTippy } from "../../tippy";
18 import { pictrsDeleteToast, toast } from "../../toast";
19 import { EmojiPicker } from "./emoji-picker";
20 import { Icon, Spinner } from "./icon";
21 import { LanguageSelect } from "./language-select";
22 import NavigationPrompt from "./navigation-prompt";
23 import ProgressBar from "./progress-bar";
24
25 interface MarkdownTextAreaProps {
26   initialContent?: string;
27   initialLanguageId?: number;
28   placeholder?: string;
29   buttonTitle?: string;
30   maxLength?: number;
31   replyType?: boolean;
32   focus?: boolean;
33   disabled?: boolean;
34   finished?: boolean;
35   showLanguage?: boolean;
36   hideNavigationWarnings?: boolean;
37   onContentChange?(val: string): void;
38   onReplyCancel?(): void;
39   onSubmit?(content: string, formId: string, languageId?: number): void;
40   allLanguages: Language[]; // TODO should probably be nullable
41   siteLanguages: number[]; // TODO same
42 }
43
44 interface ImageUploadStatus {
45   total: number;
46   uploaded: number;
47 }
48
49 interface MarkdownTextAreaState {
50   content?: string;
51   languageId?: number;
52   previewMode: boolean;
53   imageUploadStatus?: ImageUploadStatus;
54   loading: boolean;
55   submitted: boolean;
56 }
57
58 export class MarkdownTextArea extends Component<
59   MarkdownTextAreaProps,
60   MarkdownTextAreaState
61 > {
62   private id = `markdown-textarea-${randomStr()}`;
63   private formId = `markdown-form-${randomStr()}`;
64
65   private tribute: any;
66
67   state: MarkdownTextAreaState = {
68     content: this.props.initialContent,
69     languageId: this.props.initialLanguageId,
70     previewMode: false,
71     loading: false,
72     submitted: false,
73   };
74
75   constructor(props: any, context: any) {
76     super(props, context);
77
78     this.handleLanguageChange = this.handleLanguageChange.bind(this);
79
80     if (isBrowser()) {
81       this.tribute = setupTribute();
82     }
83   }
84
85   componentDidMount() {
86     const textarea: any = document.getElementById(this.id);
87     if (textarea) {
88       autosize(textarea);
89       this.tribute.attach(textarea);
90       textarea.addEventListener("tribute-replaced", () => {
91         this.setState({ content: textarea.value });
92         autosize.update(textarea);
93       });
94
95       this.quoteInsert();
96
97       if (this.props.focus) {
98         textarea.focus();
99       }
100
101       // TODO this is slow for some reason
102       setupTippy();
103     }
104   }
105
106   componentWillReceiveProps(nextProps: MarkdownTextAreaProps) {
107     if (nextProps.finished) {
108       this.setState({
109         previewMode: false,
110         imageUploadStatus: undefined,
111         loading: false,
112         content: undefined,
113       });
114       if (this.props.replyType) {
115         this.props.onReplyCancel?.();
116       }
117
118       const textarea: any = document.getElementById(this.id);
119       const form: any = document.getElementById(this.formId);
120       form.reset();
121       setTimeout(() => autosize.update(textarea), 10);
122     }
123   }
124
125   render() {
126     const languageId = this.state.languageId;
127
128     // TODO add these prompts back in at some point
129     // <Prompt
130     //   when={!this.props.hideNavigationWarnings && this.state.content}
131     //   message={I18NextService.i18n.t("block_leaving")}
132     // />
133     return (
134       <form
135         className="markdown-textarea"
136         id={this.formId}
137         onSubmit={linkEvent(this, this.handleSubmit)}
138       >
139         <NavigationPrompt
140           when={
141             !this.props.hideNavigationWarnings &&
142             !!this.state.content &&
143             !this.state.submitted
144           }
145         />
146         <div className="mb-3 row">
147           <div className="col-12">
148             <div className="rounded bg-light border">
149               <div className="d-flex flex-wrap border-bottom">
150                 {this.getFormatButton("bold", this.handleInsertBold)}
151                 {this.getFormatButton("italic", this.handleInsertItalic)}
152                 {this.getFormatButton("link", this.handleInsertLink)}
153                 <EmojiPicker
154                   onEmojiClick={e => this.handleEmoji(this, e)}
155                   disabled={this.isDisabled}
156                 ></EmojiPicker>
157                 <form className="btn btn-sm text-muted font-weight-bold">
158                   <label
159                     htmlFor={`file-upload-${this.id}`}
160                     className={`mb-0 ${
161                       UserService.Instance.myUserInfo && "pointer"
162                     }`}
163                     data-tippy-content={I18NextService.i18n.t("upload_image")}
164                   >
165                     {this.state.imageUploadStatus ? (
166                       <Spinner />
167                     ) : (
168                       <Icon icon="image" classes="icon-inline" />
169                     )}
170                   </label>
171                   <input
172                     id={`file-upload-${this.id}`}
173                     type="file"
174                     accept="image/*,video/*"
175                     name="file"
176                     className="d-none"
177                     multiple
178                     disabled={
179                       !UserService.Instance.myUserInfo || this.isDisabled
180                     }
181                     onChange={linkEvent(this, this.handleImageUpload)}
182                   />
183                 </form>
184                 {this.getFormatButton("header", this.handleInsertHeader)}
185                 {this.getFormatButton(
186                   "strikethrough",
187                   this.handleInsertStrikethrough
188                 )}
189                 {this.getFormatButton("quote", this.handleInsertQuote)}
190                 {this.getFormatButton("list", this.handleInsertList)}
191                 {this.getFormatButton("code", this.handleInsertCode)}
192                 {this.getFormatButton("subscript", this.handleInsertSubscript)}
193                 {this.getFormatButton(
194                   "superscript",
195                   this.handleInsertSuperscript
196                 )}
197                 {this.getFormatButton("spoiler", this.handleInsertSpoiler)}
198                 <a
199                   href={markdownHelpUrl}
200                   className="btn btn-sm text-muted font-weight-bold"
201                   title={I18NextService.i18n.t("formatting_help")}
202                   rel={relTags}
203                 >
204                   <Icon icon="help-circle" classes="icon-inline" />
205                 </a>
206               </div>
207
208               <div>
209                 <textarea
210                   id={this.id}
211                   className={classNames(
212                     "form-control border-0 rounded-top-0 rounded-bottom",
213                     {
214                       "d-none": this.state.previewMode,
215                     }
216                   )}
217                   value={this.state.content}
218                   onInput={linkEvent(this, this.handleContentChange)}
219                   onPaste={linkEvent(this, this.handleImageUploadPaste)}
220                   onKeyDown={linkEvent(this, this.handleKeyBinds)}
221                   required
222                   disabled={this.isDisabled}
223                   rows={2}
224                   maxLength={
225                     this.props.maxLength ?? markdownFieldCharacterLimit
226                   }
227                   placeholder={this.props.placeholder}
228                 />
229                 {this.state.previewMode && this.state.content && (
230                   <div
231                     className="card border-secondary card-body md-div"
232                     dangerouslySetInnerHTML={mdToHtml(this.state.content)}
233                   />
234                 )}
235                 {this.state.imageUploadStatus &&
236                   this.state.imageUploadStatus.total > 1 && (
237                     <ProgressBar
238                       className="mt-2"
239                       striped
240                       animated
241                       value={this.state.imageUploadStatus.uploaded}
242                       max={this.state.imageUploadStatus.total}
243                       text={
244                         I18NextService.i18n.t("pictures_uploded_progess", {
245                           uploaded: this.state.imageUploadStatus.uploaded,
246                           total: this.state.imageUploadStatus.total,
247                         }) ?? undefined
248                       }
249                     />
250                   )}
251               </div>
252               <label className="visually-hidden" htmlFor={this.id}>
253                 {I18NextService.i18n.t("body")}
254               </label>
255             </div>
256           </div>
257
258           <div className="col-12 d-flex align-items-center flex-wrap mt-2">
259             {this.props.showLanguage && (
260               <LanguageSelect
261                 iconVersion
262                 allLanguages={this.props.allLanguages}
263                 // Only set the selected language ID if it exists as an option
264                 // in the dropdown; otherwise, set it to 0 (Undetermined)
265                 selectedLanguageIds={
266                   languageId && this.props.siteLanguages.includes(languageId)
267                     ? [languageId]
268                     : [0]
269                 }
270                 siteLanguages={this.props.siteLanguages}
271                 onChange={this.handleLanguageChange}
272                 disabled={this.isDisabled}
273               />
274             )}
275
276             {/* A flex expander */}
277             <div className="flex-grow-1"></div>
278
279             {this.props.buttonTitle && (
280               <button
281                 type="submit"
282                 className="btn btn-sm btn-secondary ms-2"
283                 disabled={this.isDisabled}
284               >
285                 {this.state.loading ? (
286                   <Spinner />
287                 ) : (
288                   <span>{this.props.buttonTitle}</span>
289                 )}
290               </button>
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             {this.state.content && (
302               <button
303                 className={`btn btn-sm btn-secondary ms-2 ${
304                   this.state.previewMode && "active"
305                 }`}
306                 onClick={linkEvent(this, this.handlePreviewToggle)}
307               >
308                 {this.state.previewMode
309                   ? I18NextService.i18n.t("edit")
310                   : I18NextService.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={I18NextService.i18n.t(type)}
343         aria-label={I18NextService.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         I18NextService.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 ${I18NextService.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 }