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