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