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