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